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.
- package/docs/for-ai/skills/okstra-inspect.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/model_discovery.py +8 -2
- package/runtime/python/okstra_ctl/schedule_semantics.py +43 -32
- package/runtime/python/okstra_ctl/stage_map.py +31 -13
- package/runtime/skills/okstra-inspect/SKILL.md +2 -1
- package/runtime/skills/okstra-schedule-gen/SKILL.md +42 -7
- package/runtime/templates/reports/schedule.template.md +64 -57
- package/runtime/validators/validate-schedule.py +130 -56
|
@@ -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
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -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`
|
|
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
|
-
|
|
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 `
|
|
33
|
-
#
|
|
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
|
-
"#", "
|
|
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
|
|
600
|
+
"requires 7 columns"
|
|
600
601
|
)
|
|
601
602
|
continue
|
|
602
|
-
effort_tokens = cells[
|
|
603
|
+
effort_tokens = cells[4].split()
|
|
604
|
+
position = cells[0].strip()
|
|
603
605
|
rows.append(_AtAGlanceRow(
|
|
604
|
-
task_id=
|
|
606
|
+
task_id=f"#{position}",
|
|
605
607
|
effort=effort_tokens[0].strip("*") if effort_tokens else "",
|
|
606
|
-
days_text=cells[
|
|
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
|
|
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
|
-
|
|
646
|
+
seen: dict[str, list[_AtAGlanceRow]] = {}
|
|
641
647
|
for row in rows:
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
for
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
"
|
|
654
|
+
f"At a Glance: task {task.task_id} requires exactly one row "
|
|
655
|
+
f"at position {position}"
|
|
654
656
|
)
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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, [
|
|
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
|
|
366
|
-
"""The schema-v2 sidecar
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
3
|
-
id: {{FM_ID}}
|
|
4
|
-
tags: {{FM_TAGS}}
|
|
5
|
-
status: new
|
|
6
|
-
aliases: {{FM_ALIASES}}
|
|
2
|
+
title: {{TITLE}} — 작업 일정
|
|
7
3
|
date: {{TASK_DATE}}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
taskType: "{{FM_TASK_TYPE}}"
|
|
4
|
+
lang: "ko"
|
|
5
|
+
tags: ["schedule"]
|
|
6
|
+
status: new
|
|
12
7
|
---
|
|
13
8
|
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
| # |
|
|
32
|
-
|
|
33
|
-
| 1 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: <무엇을 왜 하는지> | `<
|
|
212
|
-
| 2 | GREEN: <무엇을 왜 하는지> | `<
|
|
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
|
-
|
|
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
|
|
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("
|
|
250
|
+
elif not title_line.rstrip().endswith(labels["title_suffix"]):
|
|
200
251
|
violations.append(
|
|
201
|
-
f'title must end with "
|
|
252
|
+
f'title must end with "{labels["title_suffix"]}"; got: {title_line!r}'
|
|
202
253
|
)
|
|
203
254
|
|
|
204
|
-
# 2.
|
|
205
|
-
|
|
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
|
-
"
|
|
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
|
|
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: # |
|
|
381
|
-
if len(cells) <
|
|
461
|
+
# header: # | 작업 | Category | Priority | Effort | Days | Risk
|
|
462
|
+
if len(cells) < 7:
|
|
382
463
|
continue
|
|
383
|
-
priority = re.sub(r"\*+", "", cells[
|
|
384
|
-
effort = re.sub(r"\*+", "", cells[
|
|
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[
|
|
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
|
-
#
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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:
|
|
712
|
-
if glossary_present
|
|
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
|
|
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
|
|
721
|
-
"`## Recommended Immediate Actions`"
|
|
795
|
+
"`## Glossary` must be the last `##` section"
|
|
722
796
|
)
|
|
723
797
|
|
|
724
798
|
# Glossary header literal check.
|