okstra 0.157.0 → 0.158.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.
@@ -236,7 +236,7 @@ Enter code mode only when the user explicitly requests it, such as "including th
236
236
 
237
237
  ## Output rules
238
238
 
239
- - Answer concisely in Korean.
239
+ - Answer in Korean, spelling out task ids, phase names, and status values on first mention.
240
240
  - Prefer project-relative paths.
241
241
  - Show disk field values as-is without normalizing.
242
242
  - Clearly indicate an awaiting-approval state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.157.0",
3
+ "version": "0.158.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.157.0",
3
- "builtAt": "2026-08-07T06:39:25.315Z",
2
+ "package": "0.158.0",
3
+ "builtAt": "2026-08-07T09:36:00.766Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -53,7 +53,12 @@ def _dispatch_effort(execution: str, role: str) -> str:
53
53
 
54
54
  @lru_cache(maxsize=1)
55
55
  def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
56
- """Live `agy models` list, or () when agy is unavailable (non-blocking)."""
56
+ """Live `agy models` ids, or () when agy is unavailable (non-blocking).
57
+
58
+ agy prints one model per line as `<id>\\t<display label>`; only the id is
59
+ comparable to a catalog execution value, so the label column is dropped.
60
+ A line with no tab is already bare and passes through unchanged.
61
+ """
57
62
  try:
58
63
  proc = subprocess.run(
59
64
  [agy_bin, "models"],
@@ -63,7 +68,8 @@ def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
63
68
  return ()
64
69
  if proc.returncode != 0:
65
70
  return ()
66
- return tuple(line.strip() for line in proc.stdout.splitlines() if line.strip())
71
+ ids = (line.split("\t", 1)[0].strip() for line in proc.stdout.splitlines())
72
+ return tuple(model_id for model_id in ids if model_id)
67
73
 
68
74
 
69
75
  def _codex_config_path() -> Path:
@@ -29,10 +29,11 @@ _STAGE_FIELDS = {"stageNumber", "title", "dependsOn", "stepCount"}
29
29
  _TASK_HEADING_RE = re.compile(r"^###\s+(\d+)\.\s+(\S.*?)\s*$", re.MULTILINE)
30
30
  _WORK_BREAKDOWN_HEADER = ["Stage", "Title", "Steps", "Depends On", "Days"]
31
31
  _WORK_BREAKDOWN_SEPARATOR = ["---:", "---", "---:", "---", "---:"]
32
- # No `taskType`: it printed okstra's own phase name at a reader who has no
33
- # phases, and the per-task Status field already states the same thing in words.
32
+ # No `Task ID` and no `taskType`: the schedule is shared with people who do not
33
+ # run okstra, so a task is named by its work and a row binds to the selection by
34
+ # its `#` position — the same rule the `### <n>.` blocks follow.
34
35
  _AT_A_GLANCE_HEADER = [
35
- "#", "Task ID", "Title", "Category", "Priority", "Effort", "Days", "Risk",
36
+ "#", "작업", "Category", "Priority", "Effort", "Days", "Risk",
36
37
  ]
37
38
  _EFFORT_HEADER = ["Size", "Criteria", "Day(s)"]
38
39
  _EFFORT_SIZES = {"S", "M", "L", "XL", "XXL"}
@@ -596,17 +597,16 @@ def _parse_at_a_glance_table(
596
597
  task_id = cells[1] if len(cells) > 1 else "<unknown>"
597
598
  violations.append(
598
599
  f"At a Glance: noncanonical row for task {task_id} "
599
- "requires 8 columns"
600
+ "requires 7 columns"
600
601
  )
601
602
  continue
602
- effort_tokens = cells[5].split()
603
+ effort_tokens = cells[4].split()
604
+ position = cells[0].strip()
603
605
  rows.append(_AtAGlanceRow(
604
- task_id=cells[1],
606
+ task_id=f"#{position}",
605
607
  effort=effort_tokens[0].strip("*") if effort_tokens else "",
606
- days_text=cells[6],
607
- days=_parse_day_range(
608
- cells[6], f"At a Glance {cells[1]}"
609
- ),
608
+ days_text=cells[5],
609
+ days=_parse_day_range(cells[5], f"At a Glance row {position}"),
610
610
  ))
611
611
  return rows, violations
612
612
 
@@ -621,7 +621,7 @@ def _parse_at_a_glance_rows(
621
621
  if split_pipe_row(line) == _AT_A_GLANCE_HEADER
622
622
  ]
623
623
  if not header_indexes:
624
- return [], ["At a Glance: missing canonical 8-column table"]
624
+ return [], ["At a Glance: missing canonical 7-column table"]
625
625
  rows: list[_AtAGlanceRow] = []
626
626
  violations: list[str] = []
627
627
  for header_index in header_indexes:
@@ -636,27 +636,37 @@ def _parse_at_a_glance_rows(
636
636
  def _validated_at_a_glance_rows(
637
637
  text: str, tasks: tuple[SelectionTask, ...],
638
638
  ) -> tuple[dict[str, _AtAGlanceRow], list[str]]:
639
+ """Rows keyed by task-id, resolved from each row's `#` position.
640
+
641
+ The table names tasks by their work, so position is what ties a row to the
642
+ selection — the n-th row is the n-th task, the same rule the `### <n>.`
643
+ blocks follow.
644
+ """
639
645
  rows, violations = _parse_at_a_glance_rows(text)
640
- indexed: dict[str, list[_AtAGlanceRow]] = {}
646
+ seen: dict[str, list[_AtAGlanceRow]] = {}
641
647
  for row in rows:
642
- indexed.setdefault(row.task_id, []).append(row)
643
- known_task_ids = {task.task_id for task in tasks}
644
- for task_id, matches in indexed.items():
645
- if len(matches) > 1:
646
- violations.append(f"At a Glance: duplicate Task ID {task_id}")
647
- if task_id not in known_task_ids:
648
- violations.append(f"At a Glance: unknown Task ID {task_id}")
649
- for task in tasks:
650
- if len(indexed.get(task.task_id, [])) != 1:
648
+ seen.setdefault(row.task_id, []).append(row)
649
+ validated: dict[str, _AtAGlanceRow] = {}
650
+ for position, task in enumerate(tasks, start=1):
651
+ matches = seen.pop(f"#{position}", [])
652
+ if len(matches) != 1:
651
653
  violations.append(
652
- f"At a Glance: task {task.task_id} "
653
- "requires exactly one canonical row"
654
+ f"At a Glance: task {task.task_id} requires exactly one row "
655
+ f"at position {position}"
654
656
  )
655
- validated = {
656
- task_id: matches[0]
657
- for task_id, matches in indexed.items()
658
- if task_id in known_task_ids and len(matches) == 1
659
- }
657
+ continue
658
+ row = matches[0]
659
+ validated[task.task_id] = _AtAGlanceRow(
660
+ task_id=task.task_id,
661
+ effort=row.effort,
662
+ days_text=row.days_text,
663
+ days=row.days,
664
+ )
665
+ for leftover, matches in seen.items():
666
+ violations.append(
667
+ f"At a Glance: row {leftover} has no matching task "
668
+ f"({len(tasks)} tasks selected)"
669
+ )
660
670
  return validated, violations
661
671
 
662
672
 
@@ -883,11 +893,12 @@ def _validate_task_section_coverage(
883
893
 
884
894
 
885
895
  def _extract_breakdown_rows(
886
- text: str,
896
+ text: str, tasks: tuple[SelectionTask, ...],
887
897
  ) -> tuple[list[_BreakdownRow], list[str], list[tuple[str, str]]]:
888
- glance_rows, _ = _parse_at_a_glance_rows(text)
898
+ # The n-th `### <n>.` block is the n-th selected task; neither the heading
899
+ # nor the At a Glance row prints an id to match on.
889
900
  blocks, violations = _task_section_blocks(
890
- text, [row.task_id for row in glance_rows]
901
+ text, [task.task_id for task in tasks]
891
902
  )
892
903
  if violations:
893
904
  return [], violations, blocks
@@ -1002,7 +1013,7 @@ def _validate_topological_order(
1002
1013
  def _validate_work_breakdowns(
1003
1014
  text: str, tasks: tuple[SelectionTask, ...],
1004
1015
  ) -> tuple[list[_BreakdownRow], list[str]]:
1005
- rows, violations, blocks = _extract_breakdown_rows(text)
1016
+ rows, violations, blocks = _extract_breakdown_rows(text, tasks)
1006
1017
  violations.extend(_validate_task_section_coverage(blocks, tasks))
1007
1018
  indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]] = {}
1008
1019
  for position, row in enumerate(rows):
@@ -62,7 +62,12 @@ _PLANNING_TASK_NARRATIVE_FIELDS = (
62
62
  "crossProjectDependencies",
63
63
  "dependencyMigrationRisk",
64
64
  "recommendedOption",
65
+ "requirementCoverage",
65
66
  )
67
+ # The report's own plain-language summary. `decisions`, `actions` and `blockers`
68
+ # are deliberately not carried: they ask the reader to approve something, and a
69
+ # schedule states work rather than requesting sign-off.
70
+ _REPORT_SUMMARY_FIELDS = ("headline", "outcome", "whyItMatters")
66
71
 
67
72
 
68
73
  @dataclass(frozen=True)
@@ -362,8 +367,8 @@ def parse_stage_map_file(markdown_path: Path) -> list[StageMapStage]:
362
367
  return _parse_schema_v2_stage_map(data, str(data_path))
363
368
 
364
369
 
365
- def _planning_section(markdown_path: Path) -> dict[str, Any]:
366
- """The schema-v2 sidecar's `implementationPlanning` block, `{}` for v1."""
370
+ def _schema_v2_report(markdown_path: Path) -> dict[str, Any]:
371
+ """The schema-v2 sidecar as a whole, `{}` for a v1 report."""
367
372
  data_path = Path(markdown_path).resolve().with_suffix(".data.json")
368
373
  if not data_path.exists():
369
374
  return {}
@@ -373,7 +378,12 @@ def _planning_section(markdown_path: Path) -> dict[str, Any]:
373
378
  raise StageMapError("stage_map", str(exc), str(data_path)) from exc
374
379
  if not isinstance(data, dict) or data.get("schemaVersion") != "2.0":
375
380
  return {}
376
- planning = data.get("implementationPlanning")
381
+ return data
382
+
383
+
384
+ def _planning_section(markdown_path: Path) -> dict[str, Any]:
385
+ """The report's `implementationPlanning` block, `{}` for v1."""
386
+ planning = _schema_v2_report(markdown_path).get("implementationPlanning")
377
387
  return planning if isinstance(planning, dict) else {}
378
388
 
379
389
 
@@ -397,17 +407,25 @@ def _stage_narratives(value: Any) -> dict[int, dict[str, Any]]:
397
407
 
398
408
  def load_planning_detail(markdown_path: Path) -> PlanningDetail:
399
409
  """Read one report's narrative rows; empty for a schema-v1 report."""
400
- planning = _planning_section(markdown_path)
401
- if not planning:
410
+ report = _schema_v2_report(markdown_path)
411
+ planning = report.get("implementationPlanning")
412
+ if not isinstance(planning, dict) or not planning:
402
413
  return PlanningDetail({}, {})
403
- return PlanningDetail(
404
- _stage_narratives(planning.get("stages")),
405
- {
406
- field: planning[field]
407
- for field in _PLANNING_TASK_NARRATIVE_FIELDS
408
- if field in planning
409
- },
410
- )
414
+ task = {
415
+ field: planning[field]
416
+ for field in _PLANNING_TASK_NARRATIVE_FIELDS
417
+ if field in planning
418
+ }
419
+ summary = report.get("humanSummary")
420
+ if isinstance(summary, dict):
421
+ carried = {
422
+ field: summary[field]
423
+ for field in _REPORT_SUMMARY_FIELDS
424
+ if field in summary
425
+ }
426
+ if carried:
427
+ task["reportSummary"] = carried
428
+ return PlanningDetail(_stage_narratives(planning.get("stages")), task)
411
429
 
412
430
 
413
431
  def merge_planning_detail(
@@ -79,7 +79,8 @@ When a facet needs a task but the user did not name one:
79
79
 
80
80
  ## Output Rules (shared)
81
81
 
82
- - Responses should be concise and written in Korean unless the user requests otherwise.
82
+ - Write responses in Korean unless the user requests otherwise. Spell out task ids, phase names, and
83
+ status values the first time each appears — the reader has not seen the report you are summarizing.
83
84
  - Use project-relative paths whenever possible.
84
85
  - If there is no recent report, display `--`.
85
86
  - If a specific task does not exist, explicitly state that it cannot be found based on `task-catalog.json`.
@@ -15,7 +15,7 @@ Generate a consolidated work schedule for the selected `implementation-planning`
15
15
 
16
16
  **Do NOT use** for single-task analysis (use `okstra-inspect status`) or to execute one task (use `okstra-run`).
17
17
 
18
- Public invocation: `/okstra-schedule-gen [task-group]`. A title or directive may be supplied in the host conversation. If no title is supplied, derive a default from `task-group` (e.g. `uploadFont` `uploadFontWork Schedule`).
18
+ Public invocation: `/okstra-schedule-gen [task-group]`. A title or directive may be supplied in the host conversation. If none is, name the schedule after the work itself — read the task titles in scope and write what they do (e.g. `폰트 업로드 경로 개선 작업 일정`). Never title it after the group token: that is a directory name, not what the work is.
19
19
 
20
20
  ## Step 0: Preflight
21
21
 
@@ -33,17 +33,34 @@ Parse the stdout JSON. `ok: true` → carry `projectRoot` as a literal string an
33
33
  If the call fails with `unknown command: preflight`, the `okstra` binary on PATH predates this skill — tell the user to update it (`npm i -g okstra@latest`), then stop (`/okstra-setup` does not update the binary).
34
34
  <!-- END FRAGMENT: preflight-outdated-cli -->
35
35
 
36
+ ## What the finished document looks like
37
+
38
+ The rules below are long because each one was learned from a published schedule
39
+ that failed a reader. This is the shape they add up to — read it once before
40
+ Step 4, and again at Step 7.
41
+
42
+ | Section | Holds | Never holds |
43
+ |---|---|---|
44
+ | `## At a Glance` | one row per task: work name, category, priority, effort, days, risk | task-ids, taskType, a `Task ID` column |
45
+ | `## Executive Summary` | `**목적**`, `**달라지는 것**` (limits included), scope/order, how the days were derived; then the effort and priority/risk scales | okstra vocabulary, the plan's review history |
46
+ | `## Task Dependency Graph` | cross-task edges — **omitted entirely** when there are none | stage order (the Work Breakdown owns it) |
47
+ | `## Gantt Chart` | one bar per selected stage, label and bar only | `days=`, per-row `! crit`/`est` |
48
+ | `## Task Details` | per task: Item/Detail, Problem, Solution, Work Breakdown, one block per stage (`Steps` + `Exit criteria`), Verification Commands, Rollback | `Value`, `Acceptance`, a risk section, a cross-task section, a next-actions list |
49
+
50
+ Every one of those "never holds" entries was in a shipped schedule. Four
51
+ restated a table that sat directly above or below them.
52
+
36
53
  ## Audience & authority (READ FIRST — drives everything below)
37
54
 
55
+ **The schedule is shared with people who do not run okstra.** It is the team's work-plan document, not a record of a run. The body names neither the tool nor its vocabulary — `okstra`, `task-group`, `task-key`, `taskType`, a run's phase names — and the validator refuses a body that does. The title is the work's name, the one metadata line is `> 작성일 <YYYY-MM-DD> · 대상 저장소 <repo>`, and At a Glance identifies each task by name rather than by task-id. That extends to paths: a plan often puts its own scaffolding under the run's working tree — `.okstra/…`, or the `qa/…` scratch it holds — and those are places the reader cannot open and does not own. Name the artefact instead ("계약 캡처 도구", "위반 대장") and keep the path out. Real repository paths — `package.json`, `src/**`, `eval/**`, `.github/workflows/**` — are content and stay.
56
+
38
57
  **The schedule is a client-facing work plan.** It assumes the team has all permissions and can proceed without further approval. Even when the underlying per-task reports flag blocking items, missing approvals, or "items requiring user confirmation", **the schedule MUST NOT surface them** — those belong in the internal report. Never emit a decision checklist, a `#### Items requiring user confirmation` sub-section, `Done`/`Ready?`/`Blocking Decisions` columns, or checkbox lists; "Status" reflects work phase only.
39
58
 
40
59
  **Assume the user and their team hold full authority and every permission required.** External approvals, access grants, sign-off, and vendor coordination are treated as already satisfied unless a report names a concrete external dependency outside the user's control. Concretely:
41
60
 
42
61
  - **Effort sizing & day totals** count engineering work only — strip approval-waiting / coordination buffers from source sizings (note the adjustment in `## Executive Summary` if material).
43
62
  - **Gantt bars** represent engineering duration only; no dead-time gaps for approval cycles. `(after <TASK-ID>)` marks genuine engineering dependencies only.
44
- - **Risk Mitigation Strategy** lists real engineering risks (data loss, regression surface, rollback path) permission/coordination items are dropped.
45
- - **Recommended Immediate Actions / Next Action** are concrete engineering steps; items like "permission check", "approval request", "stakeholder alignment" MUST NOT be emitted.
46
- - **Cross-Task Dependencies** covers engineering coupling only (shared modules, release order, package versions).
63
+ - **Steps and Exit criteria** are concrete engineering work; items like "permission check", "approval request", "stakeholder alignment" MUST NOT be emitted.
47
64
 
48
65
  **The schedule must be self-contained.** Opaque codes pulled from internal reports (`FC-5`, `UC-12`, `M1`, decision-item letters, …) must not appear unresolved. Choose one per identifier: **Form A** (≤3 codes) — replace the code inline with a 5–20 character one-line description of the item; **Form B** (≥4 recurring codes) — keep the codes and emit a `## Glossary` table as the last section resolving every one. Decision-item letters (`A1`, `B2`, …) are approval items and may not appear at all. TASK-IDs listed in `## At a Glance` need neither.
49
66
 
@@ -117,7 +134,7 @@ Record the chosen `selectedStages` for this task. Reject and re-prompt any custo
117
134
 
118
135
  The canonical categories come from `scripts/okstra_ctl/work_categories.py::WORK_CATEGORIES`. Do not infer or publish another category.
119
136
 
120
- **There are no phase buckets and no priority table.** `## Task Details` holds one `### <n>. <Title>` block per in-scope task, numbered from 1 in At a Glance order. The heading is the work's name — the task-id is a machine key and belongs in the At a Glance row, not in a heading the reader has to decode; the block binds to its row by number. At a Glance is already sorted Priority-first, so that ordering carries priority; sequence lives in the stages; and `## Recommended Immediate Actions` is the one place that says what to do next. In the per-task `Item / Detail` table, `Status` says where the work stands in plain words — "구현 계획 승인됨 — 실행 미착수", not `implementation-planning / implementation-planning`. Derive it from `taskType` and `workflow.currentPhase`, but never print those tokens: they are okstra's phase names and the reader has no phases.
137
+ **There are no phase buckets and no priority table.** `## Task Details` holds one `### <n>. <Title>` block per in-scope task, numbered from 1 in At a Glance order. The heading is the work's name — the task-id is a machine key and belongs in the At a Glance row, not in a heading the reader has to decode; the block binds to its row by number. At a Glance is already sorted Priority-first, so that ordering carries priority; sequence lives in the stages; and what to do next is Stage 1's first step. In the per-task `Item / Detail` table, `Status` says where the work stands in plain words — "구현 계획 승인됨 — 실행 미착수", not `implementation-planning / implementation-planning`. Derive it from `taskType` and `workflow.currentPhase`, but never print those tokens: they are okstra's phase names and the reader has no phases.
121
138
 
122
139
  Order At a Glance by Priority (`P0` first), breaking ties by `workCategory` in this canonical order: `bugfix`, `feature`, `refactor`, `ops`, `improvement`. A task whose `workCategory` is unmatched or missing sorts last within its priority and keeps its raw value in the `Category` cell — never substitute a category that is not in that list.
123
140
 
@@ -134,10 +151,16 @@ Selecting which source rows belong in a schedule is not summarising. Dropping a
134
151
 
135
152
  **Stage titles and every table cell are written in the schedule's prose language.** Headings and field labels stay English literals; the content beside them does not. A Work Breakdown whose `Title` column is English next to a `Steps` table in Korean makes the reader switch languages inside one block. The selection contract carries the same titles the schedule prints, so the two stay comparable.
136
153
 
137
- **A stage block is `Steps` plus `Exit criteria`, and nothing else.** The `Steps` table comes from `stepwiseExecution` one row per step with what it does (keep the leading `RED:` / `GREEN:` marker), the files it touches, and what the executor should see when it succeeds, from that step's `expected`. `Exit criteria` is one line from `exitContract` saying when the stage is finished.
154
+ **`## Executive Summary` opens with `**목적**` and `**달라지는 것**`; the validator requires both.** A reader who opens only that section must learn why the work happens and what is true when it ends scope and day arithmetic alone say neither. The material is in the report's `humanSummary` (`headline`, `outcome`, `whyItMatters`), which `okstra stage-map` carries as `planning.reportSummary`. Rewrite it: that field addresses an approver ("your two questions", "the option you approved"), and that framing must not survive. `decisions`, `actions` and `blockers` are not carried at all they ask for sign-off.
155
+
156
+ **State the limits in the outcome list.** When the work stops short of what its name suggests, the summary says so. Here the migration ships with three ports still vendor-shaped, so the schedule says "디렉터리 구조 완료, 격리 미완" rather than letting the reader assume the isolation is finished. A summary that only lists wins is the kind a reader stops trusting on the first surprise.
157
+
158
+ **A stage block is `Steps` plus `Exit criteria`, and nothing else.** The `Steps` table comes from `stepwiseExecution` — one row per step with what it does (keep the leading `RED:` / `GREEN:` marker), the files it touches, and what the executor should see when it succeeds, from that step's `expected`. In the 대상 column separate entries with `<br>`, not a comma: a comma-joined path list stretches that column and squeezes the two text columns into a gutter. `Exit criteria` is one line from `exitContract` saying when the stage is finished.
138
159
 
139
160
  Do not render `sliceValue` — it argues why the plan sliced the work this way, which is a reviewer's question, not an implementer's. Do not render `acceptance` alongside `exitContract`: they restate each other. Measured on one real plan, `acceptance`'s four claims all reappeared in `exitContract`, which then added two more. Sort `Verification Commands` `pre` → `mid` → `post` and introduce those three values above the table — source order interleaves them, and a reader working down the table would run a precondition after the checks it gates.
140
161
 
162
+ **The schedule has no risk section, no cross-task section and no next-actions list.** Every row those carried restated a stage: a risk's mitigation *is* the stage that answers it, an upstream dependency *is* the step that gates on it, and the next action *is* Stage 1's first step. Carry `dependencyMigrationRisk` and `crossProjectDependencies` by making sure the stage they point at says what they demand — and put anything genuinely not a stage (a freeze agreed with another team, a coupling the stages only imply) in that task's **Solution** or **Repo** cell.
163
+
141
164
  **Transcribing means the content, never the source row's `id`.** `VC-001`, `RB-003`, `XP-001`, `DM-002`, `EO-015` and their kin index a report the schedule's reader cannot open, so the self-contained rule rejects them and the validator enforces it. Drop the id column, and where a row's body cites a sibling code, replace the citation with the thing it names ("the boot probe asserts all 29 keys", not "VC-006").
142
165
 
143
166
  ### Step 5: Gantt decision (render by default)
@@ -227,6 +250,15 @@ Reached only after both Step 5.5 gates return `pass`. Promote the verified same
227
250
  2. Run `python3 ~/.okstra/lib/validators/validate-schedule.py <output-path>` as a final format check; use the repository validator only when the installed validator is absent.
228
251
  3. Delete the temporary `.selection.json` only after that final check passes. Do not print the Step 8 message while the selection contract still exists or validation is failing.
229
252
 
253
+ **Then read the document once as its reader.** The validator settles structure, arithmetic, controlled vocabulary and run-internal leakage; it cannot judge any of the following, and each has shipped in a published schedule before:
254
+
255
+ - Does the summary say **why** the work happens and what is **true when it ends** — including what stays unfinished? A list of wins that omits a known limit is the kind a reader stops trusting at the first surprise.
256
+ - Does every step say what to **do**, or does some row only explain why the plan chose this shape? The second is the report's voice, not the schedule's.
257
+ - Does any sentence assume the reader watched the plan being argued — a cited objection, a prior revision, "the reporter's rationale"?
258
+ - Is anything said twice? A section that restates a table, a cell that repeats its neighbour, a sentence that ends "…is in the table below" — the redirect is the tell.
259
+ - Would a reader who is not on this team understand every code and label on the page?
260
+ - If a translation exists, do its section count, stage blocks, steps per stage, verification rows, rollback rows and day figures match the original exactly? Compare the counts; a translation that drops rows looks complete on its own.
261
+
230
262
  ### Step 8: Completion message (Korean)
231
263
 
232
264
  ```
@@ -253,7 +285,10 @@ Reached only after both Step 5.5 gates return `pass`. Promote the verified same
253
285
 
254
286
  ## Output Rules
255
287
 
256
- - All user-facing messages in Korean; schedule body prose Korean, identifiers/headings/field labels English (template literals).
288
+ - All user-facing messages in Korean; schedule body prose Korean by default, headings/field labels English (template literals).
289
+ - **The document declares its language** as `lang:` in frontmatter, and the validator checks it against that language's labels — a faithful translation must not fail for being a translation. `SCHEDULE_LABELS` in `validators/validate-schedule.py` is the SSOT for which languages exist and what their title suffix, metadata line, summary labels and scale headers are. Writing a schedule in a language absent from that table means adding it there first.
290
+ - **A translated schedule is a sibling file, not a rewrite**: `<name>-<lang>.md` beside the original. It carries the same structure — same section count, same stage blocks, same step count per stage, same verification and rollback row counts, same day figures. Compare those counts against the original before publishing; a translation that quietly drops rows is the failure mode here, and it does not show up in either gate.
291
+ - The document names no okstra concept. Title, metadata line and At a Glance carry the work's name and its repository — not the task-key, the task-group, or the run's phase.
257
292
  - Use project-relative paths in completion messages.
258
293
  - Each detail `Status` is derived from `taskType` and `workflow.currentPhase` but written in plain words; `workStatus` only filters candidates and is never printed.
259
294
  - Per-task section Work Breakdown rows are **selected stages**, not free-form items; done stages are summarized, never scheduled forward.
@@ -1,20 +1,31 @@
1
1
  ---
2
- title: OKSTRA Schedule - {{TASK_KEY}}
3
- id: {{FM_ID}}
4
- tags: {{FM_TAGS}}
5
- status: new
6
- aliases: {{FM_ALIASES}}
2
+ title: {{TITLE}} — 작업 일정
7
3
  date: {{TASK_DATE}}
8
- task-id: "{{TASK_ID}}"
9
- task-group: "{{TASK_GROUP}}"
10
- project-id: "{{PROJECT_ID}}"
11
- taskType: "{{FM_TASK_TYPE}}"
4
+ lang: "ko"
5
+ tags: ["schedule"]
6
+ status: new
12
7
  ---
13
8
 
14
- # <Title> Work Schedule
9
+ <!-- `lang` decides which labels the validator checks. Section headings and
10
+ field labels (`## At a Glance`, `**Problem**:`, `| Stage | Title | …`) stay
11
+ English literals in every language; the words around them follow `lang`.
12
+ Known languages and their labels live in `SCHEDULE_LABELS` in
13
+ `validators/validate-schedule.py` — add a language there before writing one.
14
+
15
+ ko: title `— 작업 일정`, meta `> 작성일 … · 대상 저장소 …`,
16
+ summary `**목적**` / `**달라지는 것**`, scale `| Priority | 기준 |`
17
+ fr: title `— Planning de travail`, meta `> Rédigé le … · Dépôt concerné …`,
18
+ summary `**Objectif**` / `**Ce qui aura changé à la fin**`,
19
+ scale `| Priority | Critère |` -->
20
+
21
+ <!-- The schedule is shared with people who do not run okstra. Neither the tool
22
+ nor its vocabulary — task-group, task-key, taskType, the run's phase names —
23
+ appears anywhere in the body, and the validator refuses a body that carries
24
+ them. Frontmatter stays tooling-only and is not rendered. -->
15
25
 
16
- > Generated: <YYYY-MM-DD HH:MM> | Project: <project-id> | Task Group: <task-group>
17
- > Source: okstra <mode> (<N> tasks included, <M> done excluded)
26
+ # <작업 이름> 작업 일정
27
+
28
+ > 작성일 <YYYY-MM-DD> · 대상 저장소 <repo>
18
29
 
19
30
  ---
20
31
 
@@ -28,9 +39,9 @@ taskType: "{{FM_TASK_TYPE}}"
28
39
  implementation-planning does not estimate duration. `[NEEDS-PLANNING]` is
29
40
  reserved for a task with NO planning report at all. -->
30
41
 
31
- | # | Task ID | Title | Category | Priority | Effort | Days | Risk |
32
- |---|---------|-------|----------|----------|--------|------|------|
33
- | 1 | <TASK-ID> | <Title> | <category> | <P0~P3> | <S/M/L/XL/XXL> | <X.X> ~ <Y.Y> (est) | <risk> |
42
+ | # | 작업 | Category | Priority | Effort | Days | Risk |
43
+ |---|------|----------|----------|--------|------|------|
44
+ | 1 | <작업 이름> | <category> | <P0~P3> | <S/M/L/XL/XXL> | <X.X> ~ <Y.Y> (est) | <risk> |
34
45
 
35
46
  **Effort distribution**: S × <n> / M × <n> / L × <n> / XL × <n> / XXL × <n>
36
47
  **Risk distribution**: Very Low × <n> / Low × <n> / Medium × <n> / Med-High × <n> / High × <n>
@@ -40,13 +51,30 @@ taskType: "{{FM_TASK_TYPE}}"
40
51
 
41
52
  ## Executive Summary
42
53
 
43
- <2-4 sentences what this schedule covers and the execution strategy.>
54
+ <!-- A reader who opens only this section must learn why the work is being done
55
+ and what is true when it ends. Both labels are required and the validator
56
+ checks for them.
57
+
58
+ `목적` — the problem in today's system, in its own terms. Not "migrate to
59
+ NestJS" (that is the what); rather what is wrong or missing now and what
60
+ this changes about it. Source: the report's `humanSummary.headline` /
61
+ `whyItMatters`, rewritten for someone who never saw the report — it is
62
+ written at an approver ("your two questions", "the option you approved")
63
+ and that framing must not survive into the schedule.
64
+
65
+ `달라지는 것` — a bullet list of the end state, each item something a
66
+ reader could later check. State the limits too: if the work stops short of
67
+ what its name suggests, say so here rather than letting the reader assume
68
+ otherwise. -->
44
69
 
45
- <!-- One bullet per in-scope task, naming the stages actually selected. This is
46
- the stage roster, not a priority bucket — At a Glance is sorted
47
- Priority-first and that ordering is where priority lives. -->
70
+ **목적** <오늘 무엇이 문제이고 작업이 그것을 어떻게 바꾸는지>
48
71
 
49
- - **<TASK-ID>**: stages <n>–<m> selected (<total> steps) — <summary>
72
+ **달라지는 것**
73
+
74
+ - <완료 시점에 참이 되는 것, 확인 가능한 형태로>
75
+ - <미완으로 남는 것이 있으면 그것도>
76
+
77
+ **범위와 순서** — <몇 개 stage / step, 의존 형태, 병행 가능한 준비 작업>
50
78
 
51
79
  ### Effort Sizing Criteria
52
80
 
@@ -195,8 +223,22 @@ Stage 3 ██████░░░░
195
223
  executor should see when it succeeds — the step's own `expected`, written
196
224
  as an outcome they can check, not pasted.
197
225
 
226
+ The 대상 column separates entries with `<br>`, never a comma — a
227
+ comma-joined list stretches the column and pushes the two text columns
228
+ into a narrow gutter. It lists repository paths only. A plan that scaffolds under
229
+ the run's working tree (`.okstra/…`, `qa/…`) names the artefact there
230
+ instead — "계약 캡처 도구", "위반 대장" — because the reader neither owns
231
+ nor can open that tree. The validator refuses such a path.
232
+
198
233
  `Exit criteria` is one line from `exitContract`: when the stage is done.
199
234
 
235
+ There is no risk section, no cross-task section and no "what to do next"
236
+ list. Every row those carried restated a stage — a risk's mitigation is
237
+ the stage that answers it, an upstream dependency is the step that gates
238
+ on it, and the next action is Stage 1's first step. Anything that is
239
+ genuinely not a stage (a freeze agreed with another team, a coupling the
240
+ stages only imply) belongs in that task's **Solution** or **Scope**.
241
+
200
242
  Do NOT render `sliceValue`. It argues why the plan sliced the work this
201
243
  way, which is a reviewer's question; an implementer gets nothing from it.
202
244
  Do NOT render `acceptance` next to `exitContract` — they restate each
@@ -208,8 +250,8 @@ Stage 3 ██████░░░░
208
250
 
209
251
  | Step | 작업 | 대상 | 기대 결과 |
210
252
  |---:|---|---|---|
211
- | 1 | RED: <무엇을 왜 하는지> | `<files>` | <성공했을 때 보이는 것> |
212
- | 2 | GREEN: <무엇을 왜 하는지> | `<files>` | <성공했을 때 보이는 것> |
253
+ | 1 | RED: <무엇을 왜 하는지> | `<file>`<br>`<file>` | <성공했을 때 보이는 것> |
254
+ | 2 | GREEN: <무엇을 왜 하는지> | `<file>` | <성공했을 때 보이는 것> |
213
255
 
214
256
  **Exit criteria**: <이 stage 가 끝났다고 판정하는 상태>
215
257
 
@@ -240,41 +282,6 @@ Stage 3 ██████░░░░
240
282
 
241
283
  ---
242
284
 
243
- ## Cross-Task Dependencies & Shared Concerns
244
-
245
- <!-- When the planning report carries `crossProjectDependencies`, render one row
246
- per entry before any prose. Prose covers only what those rows do not. -->
247
-
248
- | Direction | Project | Required Work | Verification Signal |
249
- |-----------|---------|---------------|---------------------|
250
- | upstream-precondition | <project> | <requiredWork> | <verificationSignal> |
251
-
252
- <free-form prose — inter-task overlap, shared packages, release order>
253
-
254
- ---
255
-
256
- ## Risk Mitigation Strategy
257
-
258
- <!-- When the planning report carries `dependencyMigrationRisk`, render one row
259
- per entry. Additional numbered prose items may follow for risks the report
260
- does not carry. -->
261
-
262
- | Kind | Item | Impact | Mitigation |
263
- |------|------|--------|------------|
264
- | order | <item> | <impact> | <mitigation> |
265
-
266
- ---
267
-
268
- ## Recommended Immediate Actions
269
-
270
- <!-- The single place the schedule says what to do next. With several tasks in
271
- scope, lead each task's first action with its `<TASK-ID>` and keep them in
272
- At a Glance order — that ordering is already Priority-first, so a separate
273
- priority table would restate the At a Glance row and this list at once. -->
274
-
275
- - <action>
276
- - <action>
277
-
278
285
  <!-- OPTIONAL: ## Glossary
279
286
  Include ONLY when the body uses opaque codes (FC-N, UC-N, M-N, ...).
280
287
  Header literal `| Code | Description |` is required. Every code in body
@@ -37,9 +37,6 @@ REQUIRED_SECTIONS_IN_ORDER: list[str] = [
37
37
  "## At a Glance",
38
38
  "## Executive Summary",
39
39
  "## Task Details",
40
- "## Cross-Task Dependencies & Shared Concerns",
41
- "## Risk Mitigation Strategy",
42
- "## Recommended Immediate Actions",
43
40
  ]
44
41
 
45
42
  # Both are omitted when they carry nothing: a single-task schedule has no
@@ -55,7 +52,50 @@ REQUIRED_EXEC_SUMMARY_SUBSECTION = "### Effort Sizing Criteria"
55
52
  # never saw the source report cannot rank work by a code the document never
56
53
  # defines — an opaque code with a friendlier shape.
57
54
  REQUIRED_SCALE_SUBSECTION = "### Priority & Risk Scale"
58
- REQUIRED_SCALE_HEADERS = ("| Priority | 기준 |", "| Risk | 기준 |")
55
+ # A reader who opens only the summary must learn why the work happens and what
56
+ # is true when it ends. Without these the section states scope and arithmetic
57
+ # and never says what the work is for.
58
+
59
+ # Section headings and field labels stay English literals in every language, but
60
+ # the words around them belong to the reader. A schedule declares its language in
61
+ # frontmatter (`lang: ko`) and is checked against that language's contract —
62
+ # otherwise a faithful translation fails for being a translation.
63
+ SCHEDULE_LABELS = {
64
+ "ko": {
65
+ "title_suffix": "\u2014 \uc791\uc5c5 \uc77c\uc815",
66
+ "meta_pattern": r"^>\s*\uc791\uc131\uc77c\s*[0-9]{4}-[0-9]{2}-[0-9]{2}",
67
+ "meta_hint": "> \uc791\uc131\uc77c <YYYY-MM-DD> \u00b7 \ub300\uc0c1 \uc800\uc7a5\uc18c <repo>",
68
+ "summary_labels": ("**\ubaa9\uc801**", "**\ub05d\ub098\uba74 \ub2ec\ub77c\uc9c0\ub294 \uac83**"),
69
+ "scale_headers": ("| Priority | \uae30\uc900 |", "| Risk | \uae30\uc900 |"),
70
+ "glance_work_column": "\uc791\uc5c5",
71
+ },
72
+ "fr": {
73
+ "title_suffix": "\u2014 Planning de travail",
74
+ "meta_pattern": r"^>\s*R\u00e9dig\u00e9 le\s*[0-9]{4}-[0-9]{2}-[0-9]{2}",
75
+ "meta_hint": "> R\u00e9dig\u00e9 le <YYYY-MM-DD> \u00b7 D\u00e9p\u00f4t concern\u00e9 <repo>",
76
+ "summary_labels": ("**Objectif**", "**Ce qui aura chang\u00e9 \u00e0 la fin**"),
77
+ "scale_headers": ("| Priority | Crit\u00e8re |", "| Risk | Crit\u00e8re |"),
78
+ "glance_work_column": "Travail",
79
+ },
80
+ }
81
+
82
+
83
+ def _schedule_language(text):
84
+ """The `lang:` the document declares, and any complaint about it."""
85
+ match = re.search(r'^lang:\s*"?([a-z]{2})"?\s*$', text, re.MULTILINE)
86
+ if match is None:
87
+ return "", [
88
+ "missing `lang: <code>` in frontmatter — the validator checks the "
89
+ "document against that language's labels (known: "
90
+ + ", ".join(sorted(SCHEDULE_LABELS)) + ")"
91
+ ]
92
+ lang = match.group(1)
93
+ if lang not in SCHEDULE_LABELS:
94
+ return "", [
95
+ "unknown `lang: " + lang + "` — known languages are "
96
+ + ", ".join(sorted(SCHEDULE_LABELS))
97
+ ]
98
+ return lang, []
59
99
 
60
100
  REQUIRED_TASK_FIELDS = [
61
101
  "**Category**",
@@ -183,6 +223,14 @@ def _outside_fenced_lines(text: str) -> list[str]:
183
223
  return visible
184
224
 
185
225
 
226
+
227
+ def _strip_frontmatter(text: str) -> str:
228
+ """Body only. Frontmatter is tooling metadata the reader never renders."""
229
+ if not text.startswith("---\n"):
230
+ return text
231
+ end = text.find("\n---\n", 4)
232
+ return text[end + 5:] if end != -1 else text
233
+
186
234
  def _validate_format(path: Path) -> list[str]:
187
235
  if not path.exists():
188
236
  return [f"file not found: {path}"]
@@ -191,23 +239,43 @@ def _validate_format(path: Path) -> list[str]:
191
239
  lines = text.splitlines()
192
240
  visible_lines = _outside_fenced_lines(text)
193
241
  violations: list[str] = []
242
+ lang, lang_violations = _schedule_language(text)
243
+ violations.extend(lang_violations)
244
+ labels = SCHEDULE_LABELS.get(lang, SCHEDULE_LABELS["ko"])
194
245
 
195
- # 1. Title must end with "— Work Schedule"
246
+ # 1. Title must end with the schedule's own suffix
196
247
  title_line = next((ln for ln in lines if ln.startswith("# ")), "")
197
248
  if not title_line:
198
249
  violations.append("missing top-level title (# …)")
199
- elif not title_line.rstrip().endswith("— Work Schedule"):
250
+ elif not title_line.rstrip().endswith(labels["title_suffix"]):
200
251
  violations.append(
201
- f'title must end with "— Work Schedule"; got: {title_line!r}'
252
+ f'title must end with "{labels["title_suffix"]}"; got: {title_line!r}'
202
253
  )
203
254
 
204
- # 2. Metadata header lines required
205
- if not re.search(r"^>\s*Generated:.*\|\s*Project:.*\|\s*Task Group:", text, re.MULTILINE):
255
+ # 2. One metadata line: when it was written and which repository it targets.
256
+ # The schedule is shared with people who do not run okstra, so it names
257
+ # neither the tool nor its task-group / task-key vocabulary.
258
+ if not re.search(labels["meta_pattern"], text, re.MULTILINE):
259
+ violations.append(f"missing `{labels['meta_hint']}` metadata line")
260
+ body = re.sub(r"<!--.*?-->", "", _strip_frontmatter(text), flags=re.DOTALL)
261
+ # A path under the run's own working tree — `.okstra/…`, or the `qa/…`
262
+ # scratch it holds — is a place the reader cannot open and does not own.
263
+ # Name the artefact instead: "계약 캡처 도구", not its path.
264
+ tooling_path = re.search(r"`[^`\n]*(?:\.okstra/|(?<![\w/])qa/)[^`\n]*`", body)
265
+ if tooling_path:
206
266
  violations.append(
207
- "missing `> Generated: <…> | Project: <…> | Task Group: <…>` metadata line"
267
+ f"schedule cites a run-internal path {tooling_path.group(0)} that "
268
+ "tree belongs to the tool, not the reader; name the artefact instead"
269
+ )
270
+ prose = re.sub(r"`[^`\n]*`", "", body)
271
+ leaked = re.search(
272
+ r"\b(okstra|task-group|Task Group|taskType|task-key|taskKey)\b", prose
273
+ )
274
+ if leaked:
275
+ violations.append(
276
+ "schedule prose names okstra or its vocabulary "
277
+ f"({leaked.group(1)!r}) — the reader does not run it"
208
278
  )
209
- if not re.search(r"^>\s*Source:\s*okstra\b", text, re.MULTILINE):
210
- violations.append("missing `> Source: okstra <mode> …` metadata line")
211
279
 
212
280
  # 3. Required sections present and in order. The optional `## Gantt Chart`
213
281
  # sections, if present, MUST sit between Executive Summary and
@@ -264,13 +332,26 @@ def _validate_format(path: Path) -> list[str]:
264
332
  "`High` are undefined codes without it"
265
333
  )
266
334
  else:
267
- for header in REQUIRED_SCALE_HEADERS:
335
+ for header in labels["scale_headers"]:
268
336
  if header not in text:
269
337
  violations.append(
270
338
  f"{REQUIRED_SCALE_SUBSECTION} requires the header literal "
271
339
  f"{header!r}"
272
340
  )
273
341
 
342
+ # 3c. The summary states purpose and end state, not just scope.
343
+ if "## Executive Summary" in section_positions:
344
+ start = section_positions["## Executive Summary"]
345
+ rest = "\n".join(lines[start + 1:])
346
+ next_h = re.search(r"^##\s", rest, re.MULTILINE)
347
+ summary = rest[: next_h.start()] if next_h else rest
348
+ for label in labels["summary_labels"]:
349
+ if label not in summary:
350
+ violations.append(
351
+ f"`## Executive Summary` is missing {label!r} — the reader "
352
+ "must learn why the work happens and what is true when it ends"
353
+ )
354
+
274
355
  # 4. Executive Summary subsection
275
356
  effort_heading_count = sum(
276
357
  line.rstrip() == REQUIRED_EXEC_SUMMARY_SUBSECTION
@@ -377,15 +458,15 @@ def _validate_format(path: Path) -> list[str]:
377
458
  r"^\|\s*\d+\s*\|.*$", body, re.MULTILINE
378
459
  ):
379
460
  cells = split_pipe_row(row.group(0))
380
- # header: # | Task ID | Title | Category | Priority | Effort | Days | Risk
381
- if len(cells) < 8:
461
+ # header: # | 작업 | Category | Priority | Effort | Days | Risk
462
+ if len(cells) < 7:
382
463
  continue
383
- priority = re.sub(r"\*+", "", cells[4]).strip()
384
- effort = re.sub(r"\*+", "", cells[5]).strip()
464
+ priority = re.sub(r"\*+", "", cells[3]).strip()
465
+ effort = re.sub(r"\*+", "", cells[4]).strip()
385
466
  # `Effort` cell may contain extra hint like `L (5-15 files)`; pick the leading token
386
467
  effort_token = effort.split()[0] if effort else ""
387
- risk = re.sub(r"\*+", "", cells[7]).strip()
388
- task_id = cells[1]
468
+ risk = re.sub(r"\*+", "", cells[6]).strip()
469
+ task_id = cells[1] # the work's name; ids are not printed here
389
470
  if priority and priority not in ALLOWED_PRIORITY:
390
471
  violations.append(
391
472
  f"At a Glance row for {task_id!r}: Priority {priority!r} "
@@ -545,25 +626,12 @@ def _validate_format(path: Path) -> list[str]:
545
626
  break
546
627
  last_pos = pos
547
628
 
548
- # Recommended Immediate Actions must be a bullet list (no numbered list,
549
- # no checkboxes — this is a client-facing work plan, not an internal todo).
550
- if "## Recommended Immediate Actions" in section_positions:
551
- start = section_positions["## Recommended Immediate Actions"]
552
- rest = "\n".join(lines[start + 1:])
553
- next_h = re.search(r"^##\s", rest, re.MULTILINE)
554
- body = rest[: next_h.start()] if next_h else rest
555
- body = body.strip()
556
- if body and not re.search(r"^-\s+\S", body, re.MULTILINE):
557
- violations.append(
558
- "Recommended Immediate Actions must be a markdown bullet list "
559
- "(`- <action>`); numbered lists are forbidden"
560
- )
561
- if re.search(r"^-\s+\[[ xX]\]\s", body, re.MULTILINE):
562
- violations.append(
563
- "Recommended Immediate Actions must not use checkbox items "
564
- "(`- [ ] …`) — schedule is a client-facing plan, not an "
565
- "internal todo list"
566
- )
629
+ # A schedule is a work plan, not a todo list: no checkbox anywhere.
630
+ if re.search(r"^\s*[-*]\s+\[[ xX]\]\s", _strip_frontmatter(text), re.MULTILINE):
631
+ violations.append(
632
+ "schedule uses checkbox items (`- [ ] …`) — it is a work plan, "
633
+ "not an internal todo list"
634
+ )
567
635
 
568
636
  # 14. Self-contained identifiers — opaque cross-doc codes (FC-N, UC-N,
569
637
  # M-N, A1, B2, …) must be either inlined as prose (Form A) or resolved in
@@ -593,18 +661,24 @@ def _strip_code_fences(text: str) -> str:
593
661
 
594
662
  def _extract_task_id_whitelist(text: str, section_positions: dict[str, int],
595
663
  lines: list[str]) -> set[str]:
596
- """Pull TASK-IDs from the At a Glance table column 2."""
664
+ """TASK-IDs the schedule legitimately prints.
665
+
666
+ At a Glance names tasks by their work, not their id, so the ids that do
667
+ reach the page come from the two places that need to tell tasks apart when
668
+ more than one is scheduled: the Gantt row labels and the dependency graph.
669
+ """
597
670
  whitelist: set[str] = set()
598
- if "## At a Glance" not in section_positions:
599
- return whitelist
600
- start = section_positions["## At a Glance"]
601
- rest = "\n".join(lines[start + 1:])
602
- next_h = re.search(r"^##\s", rest, re.MULTILINE)
603
- body = rest[: next_h.start()] if next_h else rest
604
- for row in re.finditer(r"^\|\s*\d+\s*\|.*$", body, re.MULTILINE):
605
- cells = split_pipe_row(row.group(0))
606
- if len(cells) >= 2 and re.fullmatch(r"[A-Z][A-Z0-9_-]*", cells[1]):
607
- whitelist.add(cells[1])
671
+ for match in re.finditer(
672
+ r"^\s*([A-Z][A-Z0-9_-]*)\s+Stage\s+\d+", text, re.MULTILINE
673
+ ):
674
+ whitelist.add(match.group(1))
675
+ if "## Task Dependency Graph" in section_positions:
676
+ start = section_positions["## Task Dependency Graph"]
677
+ rest = "\n".join(lines[start + 1:])
678
+ next_h = re.search(r"^##\s", rest, re.MULTILINE)
679
+ body = rest[: next_h.start()] if next_h else rest
680
+ for token in re.findall(r"\b[A-Z][A-Z0-9_-]*\b", body):
681
+ whitelist.add(token)
608
682
  return whitelist
609
683
 
610
684
 
@@ -708,17 +782,17 @@ def _check_self_contained_identifiers(text: str, lines: list[str],
708
782
  "or add a `## Glossary` section (Form B)"
709
783
  )
710
784
 
711
- # Glossary placement: must sit AFTER `## Recommended Immediate Actions`.
712
- if glossary_present and "## Recommended Immediate Actions" in section_positions:
713
- actions_idx = section_positions["## Recommended Immediate Actions"]
714
- # Re-locate glossary by line index for ordering check.
785
+ # Glossary placement: nothing may follow it.
786
+ if glossary_present:
715
787
  glossary_line_idx = next(
716
788
  (i for i, ln in enumerate(lines) if ln.rstrip() == "## Glossary"), -1
717
789
  )
718
- if 0 <= glossary_line_idx <= actions_idx:
790
+ if glossary_line_idx >= 0 and any(
791
+ ln.startswith("## ") and ln.rstrip() != "## Glossary"
792
+ for ln in _outside_fenced_lines(text)[glossary_line_idx + 1:]
793
+ ):
719
794
  violations.append(
720
- "`## Glossary` must be the last `##` section, placed AFTER "
721
- "`## Recommended Immediate Actions`"
795
+ "`## Glossary` must be the last `##` section"
722
796
  )
723
797
 
724
798
  # Glossary header literal check.