форма записи: заголовок отвечает на вопрос своего типа
Обкатка скилла tasks на выдуманном проекте — консольные крестики-нолики на JavaScript, каталог заведён с нуля тем же скриптом. Форма вылезла раньше содержания, и правки все про неё. Заголовок отвечает на вопрос типа записи, и форм три: цель — утверждение о возможности, задача — глагол в неопределённой форме (допускается «не» перед ним), идея — назывное, без обещания. Причина не стилистическая: описательный заголовок называет состояние, а из состояния не видно, чего от работы ждут — «Ничья объявляется, пока клетки есть» одинаково читается как жалоба и как задание. Отсюда же разница индексов: роадмап — список возможностей, беклог — список работ, и перепутанные формы делают каждый похожим на другой. Механизировано ровно то, что механизируется: check считает заголовки, где первое слово не на -ть/-ти/-чь, и печатает число в блоке здоровья. Замечанием на файл нельзя — эвристика грубая, а на 97 записях двух живых проектов это поток одинаковых строк, после которого пропускают весь блок. Годность формулировки судит отдельный агент task-wording, а не чек-лист в скилле: сейчас формулировку пишет и проверяет один агент в одном контексте, а самопроверка текста слабее всего там, где формулировка казалась удачной при написании. Он ничего не правит — возвращает готовые формулировки, и заголовок с «зачем» показываются человеку, потому что по ним задачу выбирают. Ничего из того, что ловит tasks.py check, он не трогает намеренно: это был бы второй дом для правила. Заголовки секций — с прописной, после заголовка пустая строка, во всех индексах. Канонические имена стали Готово | Запланировано | Направления | Разработка (англ. Done | Planned | Directions | Tooling), сверка везде по нижнему регистру, так что старые индексы читаются по-прежнему. Отбивка живёт на записи, а не на вставке: через Plan.index проходит каждая правка индекса, а мест вставки три. Имя секции принадлежит заголовку индекса, файл на неё только ссылается. Это разрешает единственную неоднозначность починки — расхождение в одном регистре правится в пользу заголовка. Без него переезд на канон оставил бы «Готово» в роадмапе и «готово» в каждом файле цели, и свести это было бы некому. Регистр правится только у канонических секций: имена секций беклога выбирает проект. Обкатка нашла два дефекта, которых не находили ни линтеры, ни свои проверки. Вставка в пустую секцию съедала отбивку перед следующим заголовком — пропуск пустых строк теперь идёт только до первой непустой. Мета, разорванная пустой строкой, теряла поля молча: check видел лишь следствие («без рода работы») и советовал edit --kind, который дописывал второе такое же поле. Поле меты в теле стало ошибкой с названной причиной, и --fix её намеренно не чинит — какое из двух значений верное, знает человек. DECISIONS тема 20 (ЕЕЕ–ККК, следствия 82–85), changelog канона v3 пополнен двумя пунктами и двумя шагами переезда, TODO — два шага для healthlog и jellybit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -127,7 +127,7 @@ DEFAULTS = {
|
||||
# с диском первым делом, иначе кривой ключ выглядит как пропавший файл).
|
||||
PATH_KEYS = ("items", "backlog", "roadmap", "sprint", "rejected")
|
||||
|
||||
DEFAULT_SECTIONS = "ядро,инфра"
|
||||
DEFAULT_SECTIONS = "Ядро,Инфра"
|
||||
|
||||
# Секции роадмапа **канонические**, в отличие от секций беклога. Причина не в
|
||||
# любви к единообразию: у каждой своя семантика — достигнутое, очередь, долгие
|
||||
@@ -136,12 +136,14 @@ DEFAULT_SECTIONS = "ядро,инфра"
|
||||
# не несут, это полки, и остаются делом проекта.
|
||||
#
|
||||
# Пара на секцию: русское имя и английское. Проект держит **один язык на весь
|
||||
# индекс** — вперемешку это дрейф, который check называет вслух.
|
||||
# индекс** — вперемешку это дрейф, который check называет вслух. Сверка везде
|
||||
# идёт по нижнему регистру, а пишется — как здесь: заголовок предложением, с
|
||||
# прописной.
|
||||
ROADMAP_SECTIONS = (
|
||||
("готово", "done"), # достигнутое: что приложение уже умеет
|
||||
("запланировано", "planned"), # очередь значима, обоснована прозой
|
||||
("направления", "directions"), # очереди нет, тянутся долго
|
||||
("разработка", "tooling"), # инструмент и процесс, не про приложение
|
||||
("Готово", "Done"), # достигнутое: что приложение уже умеет
|
||||
("Запланировано", "Planned"), # очередь значима, обоснована прозой
|
||||
("Направления", "Directions"), # очереди нет, тянутся долго
|
||||
("Разработка", "Tooling"), # инструмент и процесс, не про приложение
|
||||
)
|
||||
ACHIEVED, PLANNED = 0, 1 # индексы в ROADMAP_SECTIONS
|
||||
DEFAULT_ROADMAP_SECTIONS = ",".join(ru for ru, _ in ROADMAP_SECTIONS)
|
||||
@@ -157,6 +159,12 @@ META_FIELD = re.compile(r"^\*\*(.+?):\*\*\s*(.*)$")
|
||||
# сами; пишется всегда новое.
|
||||
WHY_KEYS = ("зачем", "why", "хук", "hook")
|
||||
|
||||
# Ключи, которые скрипт у себя признаёт. Нужны не разбору (там ключи
|
||||
# перечислены по месту), а поиску поля, отбившегося от блока: сверять с
|
||||
# закрытым списком — единственный способ не спутать поле меты со строкой тела
|
||||
# вида `- **Важно:** …`.
|
||||
META_KEYS = {"секция", "section", "теги", "tags", *WHY_KEYS}
|
||||
|
||||
|
||||
def meta_span(lines: list[str]) -> tuple[int, int] | None:
|
||||
"""Границы мета-блока — `[начало, конец)`. None, если меты нет.
|
||||
@@ -339,7 +347,7 @@ class Plan:
|
||||
self.writes.append((path, text))
|
||||
|
||||
def index(self, lay: "Layout", kind: str, lines: list[str]) -> None:
|
||||
self.file(lay.index(kind), "\n".join(lines))
|
||||
self.file(lay.index(kind), "\n".join(spaced_sections(lines)))
|
||||
|
||||
def delete(self, path: Path) -> None:
|
||||
self.deletes.append(path)
|
||||
@@ -538,6 +546,42 @@ def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]:
|
||||
return entries, sections
|
||||
|
||||
|
||||
INFINITIVE = re.compile(r"(?:ть|ти|чь)(?:ся)?$")
|
||||
|
||||
|
||||
def action_title(title: str) -> bool:
|
||||
"""Заголовок задачи в форме действия: первое слово — глагол в неопределённой
|
||||
форме, перед ним допускается «не».
|
||||
|
||||
Эвристика, и намеренно грубая: русская морфология без словаря не разбирается,
|
||||
а «Часть данных теряется» от «Печатать поле» отличается ровно окончанием
|
||||
первого слова. Поэтому результат идёт **счётчиком в здоровье**, а не
|
||||
замечанием: ошибиться на одном заголовке дешевле, чем не заметить двадцати.
|
||||
"""
|
||||
words = re.findall(r"[^\W\d_]+", title)
|
||||
if not words:
|
||||
return False
|
||||
first = words[0].lower()
|
||||
if first in ("не", "не-") and len(words) > 1:
|
||||
first = words[1].lower()
|
||||
return bool(INFINITIVE.search(first))
|
||||
|
||||
|
||||
def spaced_sections(lines: list[str]) -> list[str]:
|
||||
"""Отбивка после заголовка секции. Заголовок, пустая строка, потом
|
||||
содержимое — во всех индексах одинаково.
|
||||
|
||||
Живёт на записи, а не на вставке: через `Plan.index` проходит **каждая**
|
||||
запись индекса, и чинить отбивку в каждом месте вставки значило бы
|
||||
полагаться на то, что ни одно из них не забыли."""
|
||||
out: list[str] = []
|
||||
for i, line in enumerate(lines):
|
||||
out.append(line)
|
||||
if SECTION.match(line) and i + 1 < len(lines) and lines[i + 1].strip():
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
|
||||
def index_lint(lines: list[str], label: str) -> list[str]:
|
||||
"""Структурные дефекты индекса, которых схлопнутый dict не видит: битые
|
||||
строки-пункты, дубли на один файл, задачи до первой секции."""
|
||||
@@ -547,6 +591,9 @@ def index_lint(lines: list[str], label: str) -> list[str]:
|
||||
for num, line in enumerate(lines, 1):
|
||||
if (m := SECTION.match(line)):
|
||||
section = m.group(1)
|
||||
if num < len(lines) and lines[num].strip():
|
||||
errors.append(f"{label}:{num}: после заголовка «{section}» нет"
|
||||
f" пустой строки; починит `check --fix`")
|
||||
continue
|
||||
if not line.startswith("- ["):
|
||||
continue
|
||||
@@ -597,14 +644,15 @@ def parse_task(path: Path) -> dict:
|
||||
rtype, bare = m.group(1).strip().lower(), m.group(2).strip()
|
||||
# Мета — блок под заголовком (task-format.md). Порядок полей свободный:
|
||||
# секция распознаётся, где бы она ни стояла.
|
||||
section, reason, why, tags, legacy = "", "", "", [], False
|
||||
section, section_raw, reason, why, tags, legacy = "", "", "", "", [], False
|
||||
if (span := meta_span(lines)):
|
||||
legacy = meta_legacy(lines, span)
|
||||
for key, value in meta_fields(lines, span):
|
||||
key = key.lower()
|
||||
if key in ("секция", "section"):
|
||||
section, _, reason = (p.strip() for p in value.partition("—"))
|
||||
section = section.rstrip(".,").lower()
|
||||
section = section.rstrip(".,")
|
||||
section_raw, section = section, section.lower()
|
||||
elif key in WHY_KEYS:
|
||||
why = value
|
||||
elif key in ("теги", "tags"):
|
||||
@@ -612,11 +660,27 @@ def parse_task(path: Path) -> dict:
|
||||
goal = next((t[len(GOAL_TAG):] for t in tags if t.startswith(GOAL_TAG)), "")
|
||||
kind = next((t[len(KIND_TAG):] for t in tags if t.startswith(KIND_TAG)), "")
|
||||
return {"title": title, "bare": bare, "type": rtype, "section": section,
|
||||
"section_raw": section_raw,
|
||||
"reason": reason, "why": why, "tags": tags, "goal": goal, "kind": kind,
|
||||
"path": path,
|
||||
"path": path, "stray_meta": stray_meta(lines, span),
|
||||
"legacy_meta": legacy, "text": text, "body": body_sections(text)}
|
||||
|
||||
|
||||
def stray_meta(lines: list[str], span: tuple[int, int] | None) -> list[str]:
|
||||
"""Поля меты, оставшиеся за пределами блока. Так выглядит мета, разорванная
|
||||
пустой строкой: разбор дочитывает блок до разрыва, а всё, что ниже,
|
||||
становится телом — и поля теряются молча. Ловить обязательно: молчаливая
|
||||
потеря «зачем» или рода работы неотличима от того, что их не задавали, а
|
||||
следующий `edit` допишет второе такое же поле в мету."""
|
||||
if span is None:
|
||||
return []
|
||||
out = []
|
||||
for j in range(span[1], len(lines)):
|
||||
if (m := META_ITEM.match(lines[j].strip())) and m.group(1).strip().lower() in META_KEYS:
|
||||
out.append(m.group(1).strip())
|
||||
return out
|
||||
|
||||
|
||||
def tasks_of(lay: Layout) -> dict[str, dict]:
|
||||
if not lay.items.is_dir():
|
||||
return {}
|
||||
@@ -836,6 +900,12 @@ def check(lay: Layout, fix: bool = False) -> int:
|
||||
if task["legacy_meta"]:
|
||||
errors.append(f"{name}: мета одной строкой — старая форма;"
|
||||
f" `check --fix` перепишет её списком")
|
||||
if task["stray_meta"]:
|
||||
errors.append(f"{name}: поле меты в теле"
|
||||
f" ({', '.join(task['stray_meta'])}) — мета разорвана"
|
||||
f" пустой строкой, и всё, что ниже разрыва, потеряно."
|
||||
f" Убери пустую строку внутри блока; `--fix` этого не"
|
||||
f" делает: какое из двух значений верное, знает человек")
|
||||
if not task["section"]:
|
||||
errors.append(f"{name}: нет поля **Секция:** в мета-блоке")
|
||||
elif task["section"] not in known[home]:
|
||||
@@ -1018,6 +1088,17 @@ def health(lay: Layout, tasks: dict, entries: dict, sections: dict) -> None:
|
||||
print(f" с открытым вопросом: {len(questions)}"
|
||||
f" — в спринт не берутся, разбор первым шагом сессии")
|
||||
|
||||
# Форма заголовка — счётчиком, а не замечанием на файл. Правило верное, но
|
||||
# проверка эвристическая, а беклог, заведённый до правила, переоформляют не
|
||||
# «заодно»: десятки одинаковых замечаний научили бы пропускать весь блок.
|
||||
flat = sorted(n[:-3] for n, t in tasks.items()
|
||||
if t["type"] in TAKEABLE and not action_title(t["bare"]))
|
||||
if flat:
|
||||
print(f" заголовков не в форме действия: {len(flat)}"
|
||||
f" ({', '.join(flat[:5])}{', …' if len(flat) > 5 else ''})"
|
||||
f" — задача отвечает на «что нужно сделать»:"
|
||||
f" «Печатать поле одним куском», а не «Поле печатается одним куском»")
|
||||
|
||||
goals = {n[:-3]: t for n, t in tasks.items() if t["type"] == GOAL}
|
||||
if goals:
|
||||
counts = {g: sum(1 for t in tasks.values() if t["goal"] == g) for g in goals}
|
||||
@@ -1152,7 +1233,11 @@ def roadmap_lint(lines: list[str], label: str) -> list[str]:
|
||||
f" закреплены; прозаический заголовок здесь — секция,"
|
||||
f" в которую может уехать цель")
|
||||
continue
|
||||
langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0] else 1)
|
||||
langs.add(0 if section.lower() == ROADMAP_SECTIONS[i][0].lower() else 1)
|
||||
if section not in ROADMAP_SECTIONS[i]:
|
||||
errors.append(f"{label}: секция «{section}» написана не как в каноне"
|
||||
f" ({' | '.join(ROADMAP_SECTIONS[i])}) — заголовок"
|
||||
f" пишется с прописной; починит `check --fix`")
|
||||
if i in seen:
|
||||
errors.append(f"{label}: секция «{section}» повторяет «{seen[i]}» —"
|
||||
f" это одна и та же секция на двух языках")
|
||||
@@ -1207,9 +1292,10 @@ def insert_entry(lines: list[str], section: str, entry: str,
|
||||
raise Usage(f"секции «{section}» в индексе нет")
|
||||
end = next((j for j in range(hi + 1, len(lines)) if SECTION.match(lines[j])), len(lines))
|
||||
if first:
|
||||
ins = hi + 1
|
||||
while ins < end and not lines[ins].strip():
|
||||
ins += 1
|
||||
# Пустые строки после заголовка пропускаются, но только если за ними
|
||||
# что-то есть: у пустой секции пропускать нечего, и строка, вставленная
|
||||
# в её конец, съела бы отбивку перед следующим заголовком.
|
||||
ins = next((j for j in range(hi + 1, end) if lines[j].strip()), hi + 1)
|
||||
elif after:
|
||||
ai = find_entry_index(lines[hi:end], after)
|
||||
if ai is None:
|
||||
@@ -2096,6 +2182,50 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
|
||||
files[task["path"]] = upd
|
||||
fixed.append(f"{name}: проставлен тег «{DECOMPOSED_TAG}» — у цели есть задачи")
|
||||
|
||||
# 5. Форма индексов: канонический регистр секций роадмапа и отбивка после
|
||||
# заголовков. Регистр правится только у **канонических** секций: имена
|
||||
# секций беклога — дело проекта, и подгонять их под свой вкус скрипт
|
||||
# права не имеет.
|
||||
canon = {n.lower(): pair for pair in ROADMAP_SECTIONS for n in pair}
|
||||
for kind, lines in idx.items():
|
||||
if kind == "roadmap":
|
||||
for j, line in enumerate(lines):
|
||||
if not (m := SECTION.match(line)):
|
||||
continue
|
||||
pair = canon.get(m.group(1).lower())
|
||||
if pair is None or m.group(1) in pair:
|
||||
continue
|
||||
want = pair[0] if m.group(1).lower() == pair[0].lower() else pair[1]
|
||||
lines[j] = f"## {want}"
|
||||
fixed.append(f"{lay.name(kind)}: секция «{m.group(1)}» → «{want}»")
|
||||
dirty.add(kind)
|
||||
if spaced_sections(lines) != lines:
|
||||
fixed.append(f"{lay.name(kind)}: отбивка после заголовков секций")
|
||||
dirty.add(kind)
|
||||
|
||||
# 6. Написание секции в мете. Имя секции принадлежит **заголовку индекса** —
|
||||
# файл на секцию только ссылается, а принадлежность сверяется по нижнему
|
||||
# регистру. Поэтому расхождение в одном регистре однозначно: побеждает
|
||||
# заголовок. Без этого шага переезд на канон оставил бы «Готово» в
|
||||
# роадмапе и «готово» в каждом файле цели.
|
||||
for name, task in tasks.items():
|
||||
kind = home_index(task)
|
||||
if not task["section_raw"] or kind not in idx:
|
||||
continue
|
||||
_, heading = find_section(idx[kind], task["section"])
|
||||
if not heading or heading == task["section_raw"]:
|
||||
continue
|
||||
# Правим уже отложенный текст, если файл трогали выше: перечитать его с
|
||||
# диска значило бы стереть проставленный шагом 4 тег.
|
||||
staged = files.get(task["path"])
|
||||
src = (staged.splitlines() if staged is not None
|
||||
else task["path"].read_text(encoding="utf-8").splitlines())
|
||||
rebuilt = meta_rebuilt(src, section=heading)
|
||||
if rebuilt is None:
|
||||
continue
|
||||
files[task["path"]] = "\n".join(rebuilt) + "\n"
|
||||
fixed.append(f"{name}: секция в мете «{task['section_raw']}» → «{heading}»")
|
||||
|
||||
plan = Plan()
|
||||
for path, text in files.items():
|
||||
plan.file(path, text)
|
||||
@@ -2145,14 +2275,14 @@ def init_files(lay: Layout, sections: list[str], roadmap_sections: list[str],
|
||||
f"- **{ROADMAP_SECTIONS[ACHIEVED][0]}** — достигнутое: строку пишет\n"
|
||||
" `tasks.py close <цель> --implemented`, ссылки на файл в ней нет —\n"
|
||||
" файл удаляется, поведение живёт в спеках;\n"
|
||||
"- **запланировано** — очередь значима и обосновывается прозой;\n"
|
||||
"- **направления** — очереди нет, тянутся долго;\n"
|
||||
"- **разработка** — инструмент и процесс, не возможности приложения.\n"
|
||||
" Отдельно, чтобы не читаться как обещание продукта.\n\n"
|
||||
f"- **{ROADMAP_SECTIONS[PLANNED][0]}** — очередь значима и обосновывается прозой;\n"
|
||||
f"- **{ROADMAP_SECTIONS[2][0]}** — очереди нет, тянутся долго;\n"
|
||||
f"- **{ROADMAP_SECTIONS[3][0]}** — инструмент и процесс, не возможности\n"
|
||||
" приложения. Отдельно, чтобы не читаться как обещание продукта.\n\n"
|
||||
"Секции **канонические** и переименованию проектом не подлежат:\n"
|
||||
"у каждой свой смысл, и в первую пишет сам `close`. Английский\n"
|
||||
"вариант — done | planned | directions | tooling, один язык на весь\n"
|
||||
"индекс.\n\n"
|
||||
f"вариант — {' | '.join(pair[1] for pair in ROADMAP_SECTIONS)},"
|
||||
" один язык на весь\nиндекс.\n\n"
|
||||
+ "".join(f"## {s}\n\n" for s in roadmap_sections))
|
||||
out[lay.index("sprint")] = empty_sprint(lay)
|
||||
out[lay.index("rejected")] = (
|
||||
|
||||
Reference in New Issue
Block a user