@dzhechkov/harness-core 0.8.11 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Контракт вывода брифа роя — РАЗБИРАЕМЫЙ, а не прозаический (ADR-001 фичи
3
+ * swarm-brief-output-contract).
4
+ *
5
+ * ЗАЧЕМ. ИЗМЕРЕНО 2026-09-04 (инцидент 33): рой из семи фоновых агентов, пять умерли на одинаковой
6
+ * ошибке связи, вся непрочитанная на диск работа исчезла вместе с ними. Выжила работа ровно у
7
+ * одного — у того, кто писал много маленьких файлов по ходу, а не один отчёт в конце.
8
+ *
9
+ * ПОЧЕМУ ДАННЫЕ, А НЕ ПРОЗА. Требование можно дописать в шаблон брифа словами, и тогда любая его
10
+ * проверка сведётся к поиску фраз: она удостоверит, что бриф СКАЗАЛ нужное, а не что по нему можно
11
+ * что-то сверить. В этом доме уже есть образец сильнее — план фичи несёт `EXPECTED_CODE_TARGETS:`,
12
+ * и гейт планов эту строку РАЗБИРАЕТ. Здесь та же форма и тот же способ отказа.
13
+ *
14
+ * ЧЕСТНЫЙ ПРЕДЕЛ, названный вслух: проверка удостоверяет, что бриф ОБЪЯВИЛ каталог и единицы. Она
15
+ * не может удостоверить, что агент будет им следовать — между брифом и поведением стоит модель.
16
+ * Настоящую разницу даст сверка содержимого каталога с объявленным списком на тике оркестратора;
17
+ * она заведена отдельной записью и СТАНОВИТСЯ ВОЗМОЖНОЙ только благодаря разбираемой форме.
18
+ *
19
+ * ГЛАВНОЕ СВОЙСТВО, КОТОРОЕ ЗДЕСЬ ЗАЩИЩАЕТСЯ: вердикт нельзя подделать текстом, который он же и
20
+ * судит. Три независимых ревью 2026-09-04 опровергли это семью воспроизводителями — усечение
21
+ * перечня одиночным `\r`, объявление внутри забора кода или HTML-комментария, украшенный ключ
22
+ * невидимый разборщику, чужой список через пустую строку, страж вложенности выключаемый отступом
23
+ * первого пункта, единица `plan` затирающая файл плана и непроверяемый каталог вывода. Каждая
24
+ * заперта именованным тестом; ОБЩЕЕ у всех семи одно, и его стоит держать в голове при любой
25
+ * следующей правке: разбор, который ЧТО-ТО ТИХО ПРОПУСКАЕТ, всегда даёт «годен» — поэтому
26
+ * неоднозначность здесь везде разрешается ГРОМКИМ отказом, а не выбором.
27
+ *
28
+ * НАЗВАННЫЕ ПРЕДЕЛЫ РАЗБОРА (каждый — в безопасную сторону, то есть в сторону отказа):
29
+ * - отступный блок кода в четыре пробела маскируется целиком: его Markdown показывает как код,
30
+ * поэтому ни объявление, ни пункт списка внутри не являются инструкцией человеку;
31
+ * - незакрытый забор кода маскирует текст до конца файла;
32
+ * - курсив подчёркиванием (`_OUTPUT_DIR_:`) невидим счётчику повторов: `_` — часть имени ключа;
33
+ * - каталог, названный кириллицей или с пробелом, отвергается;
34
+ * - число единиц ограничено (MAX_UNITS), длина имени единицы ограничена (MAX_UNIT_NAME).
35
+ */
36
+
37
+ /** Нарушение контракта: правило и человеческая причина. */
38
+ export interface BriefViolation {
39
+ readonly rule: string;
40
+ readonly detail: string;
41
+ }
42
+
43
+ /** Разобранный бриф вместе с вердиктом. */
44
+ export interface BriefCheckResult {
45
+ readonly ok: boolean;
46
+ readonly outputDir: string | null;
47
+ readonly units: readonly string[];
48
+ readonly assemblyUnit: string | null;
49
+ readonly violations: readonly BriefViolation[];
50
+ }
51
+
52
+ /**
53
+ * Обязательные объявления брифа — КАК ДАННЫЕ, чтобы их можно было перечислить в отказе и в
54
+ * шаблоне, не переписывая в трёх местах.
55
+ */
56
+ export const SWARM_BRIEF_CONTRACT = [
57
+ { key: 'OUTPUT_DIR', why: 'каталог, куда агент пишет находки; один итоговый файл теряется целиком' },
58
+ { key: 'UNITS', why: 'перечень единиц работы: по файлу на единицу, а не всё в конце' },
59
+ { key: 'ASSEMBLY_UNIT', why: 'сборка отчёта из осколков — отдельная работа со своим исполнителем' },
60
+ ] as const;
61
+
62
+ /**
63
+ * Единица не может быть одна.
64
+ *
65
+ * Одна единица — это ровно тот же «один файл в конце», ради отказа от которого контракт и заведён:
66
+ * агент, умерший на середине единственной единицы, теряет всё. Минимум две — это не порог качества,
67
+ * а граница осмысленности разбиения.
68
+ */
69
+ const MIN_UNITS = 2;
70
+
71
+ /**
72
+ * ПРЕДЕЛ НА ЧИСЛО ЕДИНИЦ (находка 9).
73
+ *
74
+ * Ограничения не было вовсе, а проверка дубликатов была квадратичной. ИЗМЕРЕНО ревью сквозным
75
+ * прогоном: 20 тыс. единиц — 0,93 с, 50 тыс. — 4,53 с, 100 тыс. — 40,25 с, и 88-92% времени в одной
76
+ * строке `units.indexOf`. Дубликаты теперь считаются через Set (те же 100 тыс. — 28 мс), но
77
+ * линейности мало: бриф с сотней тысяч единиц не бывает осмысленным, он бывает атакой или опечаткой.
78
+ * Предел назван вслух и отказ поимённый — это не «защита от DoS», это отказ разбирать бессмыслицу.
79
+ */
80
+ const MAX_UNITS = 200;
81
+
82
+ /**
83
+ * ПРЕДЕЛ НА ДЛИНУ ИМЕНИ ЕДИНИЦЫ (находка 8).
84
+ *
85
+ * Из имени выводится имя файла, а у файловой системы NAME_MAX = 255 байт. Слаг в 5000 символов
86
+ * давал «годен» и невозможный файл. 64 — не про NAME_MAX (запас там втрое), а про то, что имя
87
+ * читает человек в листинге каталога, по которому идёт сверка.
88
+ */
89
+ const MAX_UNIT_NAME = 64;
90
+
91
+ /**
92
+ * ТОЧКА В ПОСЛЕДНЕМ СЕГМЕНТЕ ПУТИ — признак файла, а не каталога.
93
+ *
94
+ * Первая редакция перечисляла расширения (`.md|.json|…`). Кросс-семейное ревью показало, что такой
95
+ * список ошибается в ОБЕ стороны: каталог `archive.json` отвергался, файл `report.log` проходил, а
96
+ * сузить список до трёх расширений можно было, не покраснив ни одного теста. Правило про точку
97
+ * строже и не имеет произвольного перечня.
98
+ *
99
+ * НАЗВАННЫЙ ПРЕДЕЛ: каталог, законно названный `v1.2`, будет отвергнут. Это ГРОМКИЙ отказ с
100
+ * понятным лечением (переименовать), а не тихий пропуск файла туда, где ждут каталог.
101
+ */
102
+ const LAST_SEGMENT_HAS_DOT = /[^/]*\.[^/]*$/;
103
+
104
+ /**
105
+ * Расширение файла единицы и имя файла плана — ЧАСТЬ КОНТРАКТА, а не соглашение.
106
+ *
107
+ * Без них обещание «оркестратор сверит каталог с перечнем» остаётся приблизительным: ревью
108
+ * показало, что по слагу `c1-identity` в каталоге можно найти `c1-identity.md`, `c1-identity.json`
109
+ * или подкаталог `c1-identity/`, и сверка становится неоднозначной. Контракт называет одно.
110
+ */
111
+ export const UNIT_FILE_EXTENSION = '.md';
112
+ export const PLAN_FILE_NAME = 'plan.md';
113
+
114
+ /** Имя файла, ожидаемое для единицы. Единственное место, где это отображение задано. */
115
+ export function unitFileName(unit: string): string { return `${unit}${UNIT_FILE_EXTENSION}`; }
116
+
117
+ /**
118
+ * Отбросить завершающие слеши перед проверкой «файл или каталог».
119
+ *
120
+ * Без этого `docs/report.md/` проходил: последний сегмент пуст, точки в нём нет (находка ревью).
121
+ * Отдельно: `.` и `..` — законные каталоги, и точка в них не признак файла.
122
+ */
123
+ function normalizeDir(p: string): string {
124
+ const trimmed = p.replace(/\/+$/, '');
125
+ return trimmed === '' || trimmed === '.' || trimmed === '..' ? 'dir' : trimmed;
126
+ }
127
+
128
+ /** Схема в пути означает не каталог на диске. */
129
+ const HAS_SCHEME = /:\/\//;
130
+
131
+ /**
132
+ * УПРАВЛЯЮЩИЕ СИМВОЛЫ ИЗ БРИФА — В ВИДИМУЮ ФОРМУ (находка 10).
133
+ *
134
+ * Значения из брифа вставлялись в текст отказа ДОСЛОВНО. Имя единицы, содержащее
135
+ * `ESC[1A ESC[G ESC[2K` + поддельную строку «dz brief-check: OK — dir …» + `ESC[1B ESC[G ESC[2K`,
136
+ * на живом терминале стирало строку REFUSED и печатало на её месте зелёный ответ, байт в байт
137
+ * совпадающий с настоящим (подтверждено `cat -v`: байты выходили из программы). Код выхода
138
+ * оставался честным — обманут был ЧЕЛОВЕК, читающий экран, а не скрипт.
139
+ *
140
+ * Функция ЭКСПОРТИРУЕТСЯ, потому что подделывается не только отказ: CLI печатает каталог и сборку
141
+ * в ЗЕЛЁНОЙ строке, куда отказ не заглядывает. Одна функция на оба слоя — единственный способ не
142
+ * забыть половину.
143
+ */
144
+ export function visibleText(s: string): string {
145
+ return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
146
+ }
147
+
148
+ /**
149
+ * Значение из брифа в тексте отказа: сначала обезврежено, потом урезано.
150
+ *
151
+ * Урезание — не косметика: слаг в 5000 символов утаскивал в отказ пять тысяч символов, и причина
152
+ * тонула в собственном воспроизводителе.
153
+ */
154
+ function shown(s: string, max = 60): string {
155
+ const v = visibleText(s);
156
+ return v.length <= max ? v : `${v.slice(0, max)}…(+${v.length - max})`;
157
+ }
158
+
159
+ /**
160
+ * РАЗДЕЛИТЕЛЬ СТРОК — И ОДИНОЧНЫЙ \r ТОЖЕ (находка 1).
161
+ *
162
+ * `split(/\r?\n/)` не режет по одиночному `\r`, а точка в LIST_ITEM его не покрывает: пункт с `\r`
163
+ * внутри не совпадал с шаблоном, и readList обрывал разбор через break. Пять единиц объявлено,
164
+ * три разобрано, вердикт «годен». Это порча ровно того машинного списка, ради сверяемости которого
165
+ * фича заведена, — и потому лечится разделителем, а не отказом.
166
+ */
167
+ const LINE_BREAK = /\r\n|\r|\n/;
168
+
169
+ /** Строка-заглушка вместо замаскированной: не пустая, не пункт, не объявление — то есть проза. */
170
+ const MASK = '\u0000masked\u0000';
171
+
172
+ /**
173
+ * ОБЪЯВЛЕНИЕ ВНУТРИ ЗАБОРА КОДА ИЛИ HTML-КОММЕНТАРИЯ — НЕ ОБЪЯВЛЕНИЕ (находка 2).
174
+ *
175
+ * Разбор был построчным и о заборах ``` / ~~~ и о `<!-- -->` не знал. Бриф, чьё НАСТОЯЩЕЕ задание —
176
+ * «один итоговый отчёт в самом конце, ничего промежуточного на диск», но с примером контракта в
177
+ * блоке ```example, получал «годен». Это ровно тот отказ, ради которого фича заведена (инцидент 33):
178
+ * проверка удостоверяла ПРИМЕР вместо задания.
179
+ *
180
+ * НАЗВАННЫЕ ПРЕДЕЛЫ, оба в безопасную сторону:
181
+ * - НЕЗАКРЫТЫЙ забор маскирует текст до конца файла. Значит бриф с болтающимся ``` получит отказ
182
+ * «не объявлено», а не тихий пропуск. Громко и с понятным лечением.
183
+ * - ОТСТУПНЫЙ блок кода в четыре пробела маскируется. Это намеренно задевает объявление внутри
184
+ * пункта списка, если оно сдвинуто на четыре пробела: ложный отказ виден и лечится снятием
185
+ * отступа, а ложный зелёный вердикт на невидимой инструкции молчалив.
186
+ *
187
+ * ДЛИНА ЗАБОРА — ЧАСТЬ ЕГО ТОЖДЕСТВА (раунд 2, дефект 1; кросс-семейное ревью). Первая редакция
188
+ * запоминала только СИМВОЛ забора и закрывающим считала любой прогон того же символа — даже КОРОЧЕ
189
+ * открывающего. По правилам Markdown закрывающий забор обязан быть не короче открывающего, и на
190
+ * этом держится обычнейший приём документации: показать пример, содержащий ```, внутри блока на
191
+ * ````. ИЗМЕРЕНО: `````example` с ``` внутри давал `{"ok":true,…,"violations":[]}` и код 0 — то есть
192
+ * объявления, лежащие ВНУТРИ внешнего забора, засчитывались настоящими. Это инцидент 33 с другого
193
+ * конца: проверка удостоверяла ПРИМЕР вместо задания. Правило — «не короче», а не «ровно столько
194
+ * же»: закрывающий забор ДЛИННЕЕ открывающего закрывать обязан, иначе пример остался бы замаскирован
195
+ * до конца файла и утащил бы за собой настоящие объявления ниже.
196
+ *
197
+ * ПОПЫТКА ВЛОЖИТЬ КОММЕНТАРИЙ — ОТКАЗ ПО НЕОДНОЗНАЧНОСТИ (раунд 2, дефект 3). `<!--`, внутри
198
+ * `<!-- вложенный -->`, ниже объявления, в конце `-->`. По правилам HTML комментарии НЕ вкладываются:
199
+ * первый же `-->` закрывает внешний блок, значит объявления ниже — живой текст, который рендер
200
+ * ПОКАЗЫВАЕТ. Автор же, открывший блок сверху, читает их как закомментированные. Два прочтения, оба
201
+ * защитимые, и выбрать между ними разборщику нечем.
202
+ *
203
+ * ПОЧЕМУ НЕ «СЧИТАТЬ ДО ПОСЛЕДНЕГО `-->`» (второй вариант, отвергнут): это выбор в пользу авторского
204
+ * прочтения, то есть УГАДЫВАНИЕ — и оно съело бы настоящие объявления, стоящие ниже законно
205
+ * закрытого комментария, дав отказ «не объявлено» вместо названной причины. Мы не выбираем: спорная
206
+ * область читается по HTML (как живой текст) и ОТДЕЛЬНО помечается, а вердикт — громкий отказ,
207
+ * называющий вложение. Это то же правило, что и везде в этом файле: неоднозначность разрешается
208
+ * отказом, а не выбором.
209
+ *
210
+ * НАЗВАННЫЙ ПРЕДЕЛ ЭТОГО ОТКАЗА (риск Р1 — «проверка, изобретающая нарушения, хуже отсутствующей»):
211
+ * спорной область становится ТОЛЬКО тогда, когда в ней есть что разбирать — объявление контракта
212
+ * или пункт списка. Заметка на полях, устроенная так же, но ничего разбираемого за собой не несущая,
213
+ * остаётся годной: отказывать по признаку, который ни на что не влияет, значит учить автора обходить
214
+ * проверку.
215
+ */
216
+ function maskFencedAndCommented(lines: readonly string[]): { lines: string[]; nestedComment: boolean } {
217
+ const out: string[] = [];
218
+ let fence: { char: string; len: number } | null = null;
219
+ let inComment = false;
220
+ /** Внутри текущего комментария встретился второй `<!--`. */
221
+ let nestedOpener = false;
222
+ /** Мы за внутренним `-->`: по HTML это уже живой текст, по замыслу автора — ещё комментарий. */
223
+ let disputed = false;
224
+ let nestedComment = false;
225
+ for (const line of lines) {
226
+ if (fence !== null) {
227
+ out.push(MASK);
228
+ // Закрывает только прогон ТОГО ЖЕ символа длиной НЕ МЕНЬШЕ открывающего. Ни ` ни ~ не
229
+ // метасимволы регулярного выражения, поэтому подставляются как есть. CommonMark разрешает
230
+ // перед закрытием только 0–3 пробела; четыре пробела оставляют строку содержимым блока.
231
+ if (new RegExp(`^ {0,3}${fence.char}{${fence.len},}[ \\t]*$`).test(line)) fence = null;
232
+ continue;
233
+ }
234
+
235
+ // У открывающего забора та же CommonMark-граница 0–3 пробела. При четырёх это одна строка
236
+ // отступного кода, а не состояние, способное спрятать последующий живой текст.
237
+ const opened = /^ {0,3}(`{3,}|~{3,})/.exec(line);
238
+ if (!inComment && opened !== null) {
239
+ const run = opened[1] ?? '';
240
+ fence = { char: run[0] ?? '`', len: run.length };
241
+ out.push(MASK);
242
+ continue;
243
+ }
244
+
245
+ // Четыре пробела превращают строку в код до того, как читатели увидят похожий на контракт
246
+ // текст. Маркеры комментария внутри отступного кода тоже не меняют HTML-состояние.
247
+ if (!inComment && /^ {4,}/.test(line)) {
248
+ out.push(MASK);
249
+ continue;
250
+ }
251
+
252
+ // Маркеры обрабатываются СЛЕВА НАПРАВО. Проверки includes по всей строке теряли второй opener
253
+ // в `<!-- заметка --> <!-- скрытое` и выпускали скрытый хвост как живой контракт.
254
+ let maskLine = inComment;
255
+ const wasDisputed = disputed;
256
+ const delimiters = line.match(/<!--|-->/g) ?? [];
257
+ for (const delimiter of delimiters) {
258
+ maskLine = true;
259
+ if (delimiter === '<!--') {
260
+ if (inComment) nestedOpener = true;
261
+ else inComment = true;
262
+ continue;
263
+ }
264
+ if (inComment) {
265
+ inComment = false;
266
+ if (nestedOpener) { disputed = true; nestedOpener = false; }
267
+ } else if (disputed) {
268
+ disputed = false;
269
+ }
270
+ }
271
+ if ((wasDisputed || disputed) && isParseable(line)) nestedComment = true;
272
+ if (maskLine) {
273
+ out.push(MASK);
274
+ continue;
275
+ }
276
+ out.push(line);
277
+ }
278
+ return { lines: out, nestedComment };
279
+ }
280
+
281
+ /**
282
+ * Строка, которую разборщик ПРОЧТЁТ: объявление контракта (голое или украшенное) либо пункт списка.
283
+ *
284
+ * Нужна ровно затем, чтобы отказ по вложенному комментарию срабатывал только там, где вложение
285
+ * что-то меняет. Оформление снимается той же `undecorate`, что и в счётчиках: иначе `**UNITS**:`
286
+ * в спорной области был бы виден человеку и невидим этой проверке — то самое расхождение, ради
287
+ * закрытия которого заведена находка 3.
288
+ */
289
+ function isParseable(line: string): boolean {
290
+ if (LIST_ITEM.test(line)) return true;
291
+ const bare = undecorate(line);
292
+ return SWARM_BRIEF_CONTRACT.some(({ key }) => new RegExp(`^\\s*${escapeKey(key)}\\s*:`).test(bare));
293
+ }
294
+
295
+ /**
296
+ * СНЯТЬ ОФОРМЛЕНИЕ ПЕРЕД СЧЁТОМ ВХОЖДЕНИЙ (находка 3).
297
+ *
298
+ * Считались только строки, где ключ голый в начале, поэтому `**OUTPUT_DIR**: /root/.ssh`,
299
+ * `> OUTPUT_DIR: /root/.ssh` и `` `OUTPUT_DIR: /root/.ssh` `` были невидимы разборщику и видимы
300
+ * человеку. Направление подделки — то, что делает находку тяжёлой: опасное значение оставляют
301
+ * ГОЛЫМ, а безопасное УКРАШАЮТ; человек читает одно, проверено другое.
302
+ *
303
+ * Украшенное объявление засчитывается в счётчик повторов, то есть ведёт к отказу по
304
+ * неоднозначности. Лечение автору очевидно: снять оформление с настоящего объявления.
305
+ *
306
+ * НАЗВАННЫЙ ПРЕДЕЛ: `_` не снимается — он часть имён ключей (`OUTPUT_DIR`), и снять его значило бы
307
+ * перестать узнавать сам ключ. Курсив подчёркиванием (`_OUTPUT_DIR_:`) остаётся невидим счётчику.
308
+ */
309
+ const DECORATION = /[*`>#~[\]]/g;
310
+ function undecorate(line: string): string { return line.replace(DECORATION, ''); }
311
+
312
+ /**
313
+ * УПРАВЛЯЮЩИЕ СИМВОЛЫ В ПУТИ (находка 7). Нулевой байт доезжал в машинный вывод как `\u0000`.
314
+ */
315
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
316
+
317
+ /**
318
+ * СЕГМЕНТ ПУТИ — ЯВНЫЙ ПЕРЕЧЕНЬ ДОПУСТИМОГО, А НЕ ПЕРЕЧЕНЬ ЗАПРЕТНОГО.
319
+ *
320
+ * Каталог вывода не проверялся как путь ВОВСЕ: `/`, `/etc`, `/root/authorized_keys_dir`,
321
+ * `docs/../../../../../root`, `C:\Windows\System32`, `docs/$(id)`, `docs/;id` — все давали «годен».
322
+ * Записи на диск в фиче сегодня нет, и это смягчает тяжесть; но шапка модуля обещает будущую сверку
323
+ * каталога, и тогда это станет путём, по которому ходят.
324
+ *
325
+ * Перечень допустимого, а не запретного, — тот же урок, что уже стоит выше про расширения файлов:
326
+ * перечень запретного ошибается в обе стороны, и его можно сузить, не покраснив ни одного теста.
327
+ *
328
+ * НАЗВАННЫЙ ПРЕДЕЛ: каталог, названный кириллицей или пробелом, будет отвергнут. Это ГРОМКИЙ отказ
329
+ * с понятным лечением, а не тихий пропуск обхода вверх.
330
+ */
331
+ const PATH_SEGMENT = /^[A-Za-z0-9._-]+$/;
332
+
333
+ /**
334
+ * Отказ по каталогу вывода КАК ПО ПУТИ, или `null`, если путь допустим.
335
+ *
336
+ * Порядок проверок — от самого опасного к самому косметическому, чтобы отказ называл ГЛАВНУЮ
337
+ * причину: управляющий символ важнее странной буквы в имени.
338
+ */
339
+ function pathRefusal(dir: string): string | null {
340
+ if (CONTROL_CHARS.test(dir)) {
341
+ return `"${shown(dir)}" contains a control character — a path never does, and in a terminal such a byte rewrites the screen. Use: OUTPUT_DIR: docs/research/<topic>`;
342
+ }
343
+ if (dir.includes('\\')) {
344
+ return `"${shown(dir)}" contains a backslash — the contract needs a relative POSIX path inside the workspace. Use: OUTPUT_DIR: docs/research/<topic>`;
345
+ }
346
+ if (dir.startsWith('/') || /^[A-Za-z]:/.test(dir)) {
347
+ return `"${shown(dir)}" is an ABSOLUTE path — the output directory must be relative to the workspace, otherwise the swarm writes outside the tree the report is assembled from. Use: OUTPUT_DIR: docs/research/<topic>`;
348
+ }
349
+ const segments = dir.replace(/\/+$/, '').split('/');
350
+ if (segments.includes('..')) {
351
+ return `"${shown(dir)}" walks up out of the workspace ("..") — the output directory must stay inside it. Use: OUTPUT_DIR: docs/research/<topic>`;
352
+ }
353
+ const strange = segments.filter((s) => !PATH_SEGMENT.test(s));
354
+ if (strange.length > 0) {
355
+ return `"${shown(dir)}" has a path segment that is not a name: ${strange.slice(0, 3).map((s) => `"${shown(s, 20)}"`).join(', ')}. Allowed in a segment: letters, digits, dot, dash, underscore. Use: OUTPUT_DIR: docs/research/<topic>`;
356
+ }
357
+ return null;
358
+ }
359
+
360
+ /**
361
+ * Имя единицы — СЛАГ, из которого выводится имя файла.
362
+ *
363
+ * Без этого требования обещание «оркестратор сверит каталог с перечнем» невыполнимо: по единице
364
+ * «исследовать API» неизвестно, какой файл искать (находка кросс-семейного ревью — она била в само
365
+ * обоснование машиночитаемой формы, а не в деталь). Слаг задаёт отображение единица → файл
366
+ * однозначно, и заодно снимает вопрос о `report` против `./report`: второе просто не слаг.
367
+ */
368
+ const UNIT_SLUG = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
369
+
370
+ /** Экранировать имя ключа: сегодня ключи без спецсимволов, но контракт объявлен расширяемым. */
371
+ function escapeKey(key: string): string { return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
372
+
373
+ /** Строка списка: тире или звёздочка, затем имя. Ведущие пробелы РАЗБИРАЮТСЯ, а не игнорируются. */
374
+ const LIST_ITEM = /^(\s*)[-*]\s+(\S.*?)\s*$/;
375
+
376
+ /**
377
+ * Прочитать скалярное объявление. Возвращает `{ value, count }`: сколько раз ключ встретился.
378
+ *
379
+ * СЧЁТ НУЖЕН, ПОТОМУ ЧТО БРИФ МОЖЕТ ОПИСЫВАТЬ САМ КОНТРАКТ. Шаблон и документация содержат те же
380
+ * ключи в примерах; молча взять ПЕРВОЕ вхождение значило бы проверить пример вместо объявления
381
+ * (находка ревью). Двусмысленность разрешается отказом, а не выбором.
382
+ */
383
+ function readScalar(lines: readonly string[], key: string): { value: string | null; count: number; decorated: number } {
384
+ const re = new RegExp(`^\\s*${escapeKey(key)}\\s*:\\s*(.*)$`);
385
+ let value: string | null = null;
386
+ let bare = 0;
387
+ let decorated = 0;
388
+ for (const line of lines) {
389
+ const m = re.exec(line);
390
+ if (m) { bare += 1; if (value === null) value = (m[1] ?? '').trim(); continue; }
391
+ // УКРАШЕННОЕ ОБЪЯВЛЕНИЕ СЧИТАЕТСЯ, НО НЕ ЧИТАЕТСЯ. Значение берётся только у голого: иначе
392
+ // разбор сам решал бы, какое из двух прочтений человек имел в виду, — а он их и не различал.
393
+ if (re.test(undecorate(line))) decorated += 1;
394
+ }
395
+ return { value, count: bare + decorated, decorated };
396
+ }
397
+
398
+ /**
399
+ * Прочитать список ПОСЛЕ строки-ключа: подряд идущие пункты, до первой строки, которая пунктом не
400
+ * является.
401
+ *
402
+ * ПУСТАЯ СТРОКА БОЛЬШЕ НЕ «ОФОРМЛЕНИЕ» (находка 4, найдена двумя ревьюерами независимо). Прежняя
403
+ * редакция на пустой строке заглядывала на следующую и, если та была пунктом, ПРОДОЛЖАЛА список.
404
+ * Разделителем абзацев работала, таким образом, не пустая строка, а проза после неё: убери прозу —
405
+ * и чужой перечень втягивался в единицы при вердикте «годен».
406
+ *
407
+ * Лечение НЕ «оборвать молча»: молчаливый обрыв — тот же тихий отказ, что и молчаливое
408
+ * присоединение, только в другую сторону (это ровно урок находки 1). Мы НЕ УМЕЕМ отличить
409
+ * продолжение списка от чужого перечня и не притворяемся, что умеем: разрыв фиксируется флагом
410
+ * `gapped`, разбор останавливается на пустой строке, а вызывающий называет неоднозначность вслух.
411
+ * Лечение автору очевидно: держать перечень сплошным.
412
+ *
413
+ * СТРОКА-НЕ-ПУНКТ ВНУТРИ ПЕРЕЧНЯ — ТОТ ЖЕ ОБРЫВ В ДРУГОЙ ОДЕЖДЕ (ADR-002, раунд 2). Раунд 1 сделал
414
+ * одиночный `\r` разделителем строк — верно — и тем самым превратил `- c3\r x` в ДВЕ строки, вторая
415
+ * из которых (` x`) пунктом не является. Разбор останавливался на ней через break и молча отбрасывал
416
+ * весь хвост перечня при вердикте «годен»: объявлено пять единиц, разобрано четыре, нарушений ноль.
417
+ * Пустую строку раунд 1 закрыл, непустую — нет.
418
+ *
419
+ * ПОЧЕМУ НЕ «ОТКАЗ НА ЛЮБОЙ ПОСТОРОННЕЙ СТРОКЕ» (вариант B в ADR-002, отвергнут). Строка-не-пункт
420
+ * ПОСЛЕ последнего пункта — нормальное окончание списка: почти в каждом реальном брифе за перечнем
421
+ * идёт проза, и поставляемый шаблон устроен именно так. Проверка, изобретающая нарушения, хуже
422
+ * отсутствующей — её учатся обходить, и она умирает. Поэтому `interrupted` заполняется ТОЛЬКО когда
423
+ * за посторонней строкой в ТОМ ЖЕ БЛОКЕ (до ближайшей пустой строки) ещё следуют пункты.
424
+ *
425
+ * ПОЧЕМУ НЕ «РАЗБИРАТЬ СКВОЗЬ» (вариант C, отвергнут): это угадывание в другую сторону — чужой
426
+ * перечень, отделённый прозой, стал бы частью единиц. Оба варианта угадывают, отличаясь лишь
427
+ * направлением ошибки; честный выход один — назвать строку, по которой решение невозможно.
428
+ */
429
+ function readList(lines: readonly string[], key: string): {
430
+ items: string[] | null; count: number; nested: boolean; gapped: boolean;
431
+ /** Сама оборвавшая строка, если после неё в том же блоке ещё есть пункты; иначе null. */
432
+ interrupted: string | null;
433
+ decorated: number;
434
+ } {
435
+ const head = new RegExp(`^\\s*${escapeKey(key)}\\s*:\\s*$`);
436
+ // СЧЁТ ВХОЖДЕНИЙ — ТАКОЙ ЖЕ, КАК У СКАЛЯРОВ. Первая редакция считала повторы только для
437
+ // `OUTPUT_DIR` и `ASSEMBLY_UNIT`, а для списка брала первое вхождение молча — и это худший
438
+ // случай из трёх: бриф, ОПИСЫВАЮЩИЙ контракт, содержит `UNITS:` в примере, и настоящий перечень
439
+ // тихо отбрасывался, а проверка печатала «ГОДЕН». Найдено ревью на живом входе.
440
+ // Украшенная строка-ключ считается наравне с голой — по той же причине, что и у скаляров.
441
+ let heads = 0;
442
+ let decorated = 0;
443
+ for (const l of lines) {
444
+ if (head.test(l)) { heads += 1; continue; }
445
+ if (head.test(undecorate(l))) { heads += 1; decorated += 1; }
446
+ }
447
+ const idx = lines.findIndex((l) => head.test(l));
448
+ if (idx < 0) return { items: null, count: heads, nested: false, gapped: false, interrupted: null, decorated };
449
+ const raw: { indent: number; text: string }[] = [];
450
+ let gapped = false;
451
+ let interrupted: string | null = null;
452
+ for (let i = idx + 1; i < lines.length; i += 1) {
453
+ const line = lines[i] ?? '';
454
+ if (line.trim() === '') {
455
+ // Разбор ВСЕГДА останавливается на пустой строке. Если за пустотами стоит ещё один пункт —
456
+ // это неоднозначность, а не продолжение: см. заголовок функции.
457
+ let j = i + 1;
458
+ while (j < lines.length && (lines[j] ?? '').trim() === '') j += 1;
459
+ if (j < lines.length && LIST_ITEM.test(lines[j] ?? '')) { gapped = true; break; }
460
+ // ЗАГЛУШКА НЕ ПРЯЧЕТ ВОЗОБНОВИВШИЙСЯ ПЕРЕЧЕНЬ (раунд 2, дефект 2). Забор кода и
461
+ // HTML-комментарий заменяются заглушкой ДО разбора, поэтому первой непустой строкой за
462
+ // пустотами оказывалась она — и перечень, возобновившийся сразу ЗА ней, был невидим обеим
463
+ // проверкам сразу: хвост отбрасывался молча при вердикте «годен». ИЗМЕРЕНО: объявлено три
464
+ // единицы, разобрано две, нарушений ноль.
465
+ //
466
+ // ПОЧЕМУ `interrupted`, А НЕ `gapped` — выбор между двумя уже существующими сигналами.
467
+ // Формально пустая строка тут есть, и `gapped` был бы не ложью. Но сигналы различаются не
468
+ // фактом, а ЛЕЧЕНИЕМ, которое они называют автору: `gapped` говорит «держите перечень
469
+ // сплошным» — то есть послал бы убирать пустую строку, чего чинить не надо; `interrupted`
470
+ // называет строку, разорвавшую перечень, и здесь разорвал его именно заслонённый забором или
471
+ // комментарием кусок. По природе заглушка — это непустая строка, не являющаяся пунктом, то
472
+ // есть ровно предмет ADR-002. Решает же дело третье: заглушка — единственное место, где
473
+ // ЧЕЛОВЕК и РАЗБОРЩИК читают разное (человек видит комментарий и сплошной вокруг него
474
+ // список), а именно это расхождение фича и закрывает.
475
+ if (j < lines.length && (lines[j] ?? '') === MASK) {
476
+ // Смотрим ТОЛЬКО внутри абзаца заглушки — до ближайшей пустой строки. Пункт за этой
477
+ // границей уже другой абзац, и его тихо отбрасывать законно (вариант A ADR-002): пример в
478
+ // заборе после перечня стоит в половине реальных брифов, и отказ на нём был бы той самой
479
+ // проверкой, которая изобретает нарушения.
480
+ for (let k = j; k < lines.length && (lines[k] ?? '').trim() !== ''; k += 1) {
481
+ if (LIST_ITEM.test(lines[k] ?? '')) { interrupted = MASK; break; }
482
+ }
483
+ }
484
+ break;
485
+ }
486
+ const m = LIST_ITEM.exec(line);
487
+ if (m === null) {
488
+ // Разбор останавливается ТАК ЖЕ, как и был, — меняется только то, называем ли мы обрыв вслух.
489
+ // Смотрим вперёд ДО БЛИЖАЙШЕЙ ПУСТОЙ СТРОКИ: она и есть граница блока. Пункт за этой границей
490
+ // — уже другой абзац, и его судьбу решает `gapped`, а не эта ветка; пункт ВНУТРИ границы
491
+ // означает, что хвост перечня был бы отброшен молча.
492
+ for (let j = i + 1; j < lines.length && (lines[j] ?? '').trim() !== ''; j += 1) {
493
+ if (LIST_ITEM.test(lines[j] ?? '')) { interrupted = line; break; }
494
+ }
495
+ break;
496
+ }
497
+ raw.push({ indent: (m[1] ?? '').length, text: (m[2] ?? '').trim() });
498
+ }
499
+ // БАЗОВЫЙ ОТСТУП — МИНИМУМ ПО СПИСКУ, А НЕ ОТСТУП ПЕРВОГО ПУНКТА (находка 5). Страж вложенности
500
+ // отключался отступлённым первым пунктом: ` - stray-first` задирал базу до двух, и подпункт
501
+ // ` - subpoint-of-c1` становился полноправной единицей при nested=false — то есть страж
502
+ // выключался ровно тем оформлением, от которого защищал.
503
+ const base = raw.reduce((min, r) => Math.min(min, r.indent), Number.POSITIVE_INFINITY);
504
+ const nested = raw.some((r) => r.indent > base);
505
+ const items = raw.filter((r) => r.indent === base).map((r) => r.text);
506
+ return { items, count: heads, nested, gapped, interrupted, decorated };
507
+ }
508
+
509
+ /**
510
+ * Проверить бриф роя на контракт вывода.
511
+ *
512
+ * Отказ ВСЕГДА поимённый: «бриф неверен» не говорит автору, что чинить, и потому его чинить не
513
+ * будут. Каждое нарушение называет ключ и причину, по которой он есть.
514
+ */
515
+ export function checkSwarmBrief(text: unknown): BriefCheckResult {
516
+ const violations: BriefViolation[] = [];
517
+ if (typeof text !== 'string' || text.trim() === '') {
518
+ return {
519
+ ok: false, outputDir: null, units: [], assemblyUnit: null,
520
+ violations: [{ rule: 'brief', detail: 'the brief is empty or not a string — there is nothing to check' }],
521
+ };
522
+ }
523
+ // Порядок обязателен: сначала строки, потом маскировка заборов и комментариев. Ключ внутри
524
+ // забора — не объявление (находка 2), и это решается ДО того, как что-либо считается.
525
+ const masked = maskFencedAndCommented(text.split(LINE_BREAK));
526
+ const lines = masked.lines;
527
+ if (masked.nestedComment) {
528
+ // ОТКАЗ ИДЁТ ПЕРВЫМ и адресован документу, а не ключу: спорна тут не одна строка, а граница
529
+ // комментария, и лечение одно на весь блок.
530
+ violations.push({
531
+ rule: 'brief',
532
+ detail: 'an HTML comment contains a second "<!--" before it closes, and declarations or list items follow the inner "-->". '
533
+ + 'HTML comments do not nest: the FIRST "-->" ends the block, so a renderer SHOWS what comes after it, while the author '
534
+ + 'who opened the block above reads the same lines as commented out. Both readings are defensible and the parser cannot '
535
+ + 'choose between them — a check that guesses here would certify an EXAMPLE as the task. Remove the inner "<!--", or close '
536
+ + 'the outer comment above the declarations.',
537
+ });
538
+ }
539
+
540
+ const dirRead = readScalar(lines, 'OUTPUT_DIR');
541
+ const outputDir = dirRead.value;
542
+ const dirPathRefusal = outputDir === null ? null : pathRefusal(outputDir);
543
+ if (dirRead.count > 1) {
544
+ violations.push({ rule: 'OUTPUT_DIR', detail: `declared ${dirRead.count} times — which value is real cannot be told; keep one` });
545
+ } else if (dirRead.decorated > 0) {
546
+ // Единственное объявление — украшенное. Отказ ИМЕНУЕТ причину: сказать «не объявлен» про
547
+ // строку, которую автор видит на экране, значит послать его чинить не то.
548
+ violations.push({ rule: 'OUTPUT_DIR', detail: 'the only declaration is decorated (bold, quote or backticks) — the parser reads a bare line, so a decorated one is visible to a human and invisible to the check. Put it bare: OUTPUT_DIR: docs/research/<topic>' });
549
+ } else if (outputDir === null || outputDir === '') {
550
+ violations.push({ rule: 'OUTPUT_DIR', detail: 'no output directory declared. Add the line: OUTPUT_DIR: docs/research/<topic>' });
551
+ } else if (/\s/.test(outputDir)) {
552
+ violations.push({ rule: 'OUTPUT_DIR', detail: `"${shown(outputDir)}" contains whitespace — a trailing comment or note silently became part of the path. Put the path alone: OUTPUT_DIR: docs/research/<topic>` });
553
+ } else if (HAS_SCHEME.test(outputDir)) {
554
+ violations.push({ rule: 'OUTPUT_DIR', detail: `"${shown(outputDir)}" is an address, not a directory on disk. Put a path: OUTPUT_DIR: docs/research/<topic>` });
555
+ } else if (dirPathRefusal !== null) {
556
+ violations.push({ rule: 'OUTPUT_DIR', detail: dirPathRefusal });
557
+ } else if (LAST_SEGMENT_HAS_DOT.test(normalizeDir(outputDir))) {
558
+ violations.push({ rule: 'OUTPUT_DIR', detail: `"${shown(outputDir)}" looks like a FILE (a dot in the name); a DIRECTORY is needed — each unit is a separate file INSIDE it. Use: OUTPUT_DIR: docs/research/<topic>` });
559
+ }
560
+
561
+ const unitsRead = readList(lines, 'UNITS');
562
+ const units = unitsRead.items ?? [];
563
+ if (unitsRead.count > 1) {
564
+ violations.push({ rule: 'UNITS', detail: `declared ${unitsRead.count} times — the real list cannot be told from an example; keep one` });
565
+ } else if (unitsRead.decorated > 0 && unitsRead.items === null) {
566
+ violations.push({ rule: 'UNITS', detail: 'the only UNITS line is decorated (bold, quote or backticks) — the parser reads a bare line. Put it bare: UNITS:' });
567
+ }
568
+ if (unitsRead.nested) {
569
+ violations.push({ rule: 'UNITS', detail: 'a nested list item is not a unit: the list is flat, and flattening sub-points would corrupt the very list the directory is compared against. Move explanations into prose.' });
570
+ }
571
+ if (unitsRead.gapped) {
572
+ violations.push({ rule: 'UNITS', detail: 'the list is broken by a blank line and resumes after it — a blank line cannot tell a continuation from a FOREIGN list, and guessing either way silently corrupts the list the directory is compared against. Keep the unit list contiguous.' });
573
+ }
574
+ if (unitsRead.interrupted !== null) {
575
+ // ОТКАЗ НАЗЫВАЕТ СТРОКУ, А НЕ ТОЛЬКО ПРАВИЛО. Оборвавшую строку часто порождает одиночный
576
+ // возврат каретки внутри пункта — глазами в редакторе её не найти, и отказ «список оборван»
577
+ // послал бы автора искать невидимое. Значение приходит из недоверенного файла, поэтому идёт
578
+ // через ту же `shown`, что все значения раунда 1: обезврежено, потом урезано (находка 10).
579
+ // ЗАМАСКИРОВАННУЮ СТРОКУ НАЗЫВАЕМ СЛОВАМИ, А НЕ ВНУТРЕННЕЙ ЗАГЛУШКОЙ. Забор кода и
580
+ // HTML-комментарий заменяются на MASK ещё до разбора; напечатать `\x00masked\x00` значило бы
581
+ // назвать строку, которой автор в своём файле не видит, — то есть нарушить то самое требование
582
+ // ADR-002, ради которого отказ вообще называет строку.
583
+ const named = unitsRead.interrupted === MASK
584
+ ? 'a fenced code block or an HTML comment'
585
+ : `"${shown(unitsRead.interrupted)}"`;
586
+ violations.push({
587
+ rule: 'UNITS',
588
+ detail: `the list is broken by a line that is not an item — ${named} — `
589
+ + 'and more items follow it in the same block, so everything after it was dropped. That line '
590
+ + 'cannot tell a continuation from a FOREIGN list, and guessing either way silently corrupts '
591
+ + 'the list the directory is compared against. A single carriage return inside an item '
592
+ + 'produces such a line invisibly. Keep the unit list contiguous.',
593
+ });
594
+ }
595
+ if (unitsRead.items === null) {
596
+ violations.push({ rule: 'UNITS', detail: 'no list of work units declared: without it there is nothing to compare the directory against' });
597
+ } else if (units.length === 0) {
598
+ violations.push({ rule: 'UNITS', detail: 'the unit list is empty — that is not "zero units", it is an unfilled declaration' });
599
+ } else if (units.length < MIN_UNITS) {
600
+ violations.push({ rule: 'UNITS', detail: `${units.length} unit(s): a single unit is the same "everything at the end" this contract exists to reject` });
601
+ }
602
+ if (units.length > MAX_UNITS) {
603
+ // Поимённые проверки ниже пропускаются осознанно: перечислять тысячи имён в отказе — значит
604
+ // утопить причину. Предел назван, лечение очевидно.
605
+ violations.push({ rule: 'UNITS', detail: `too many units (${units.length}, limit ${MAX_UNITS}) — a brief with that many units is not a plan a swarm can follow; split the work into several briefs` });
606
+ } else {
607
+ // ЛИНЕЙНАЯ проверка дубликатов через Set (находка 9): `units.indexOf` внутри filter давал
608
+ // квадрат — 100 тыс. единиц считались 40,25 с, из них 88-92% в этой строке.
609
+ const seen = new Set<string>();
610
+ const dupes = new Set<string>();
611
+ for (const u of units) { if (seen.has(u)) dupes.add(u); else seen.add(u); }
612
+ if (dupes.size > 0) {
613
+ violations.push({ rule: 'UNITS', detail: `duplicate units (${[...dupes].slice(0, 3).map((u) => shown(u, 30)).join(', ')}): two files with one name overwrite each other` });
614
+ }
615
+ const tooLong = units.filter((u) => u.length > MAX_UNIT_NAME);
616
+ if (tooLong.length > 0) {
617
+ violations.push({
618
+ rule: 'UNITS',
619
+ detail: `unit name too long (${tooLong[0]?.length ?? 0} chars, limit ${MAX_UNIT_NAME}): `
620
+ + `"${shown(tooLong[0] ?? '', 30)}". The file name is derived from it as <unit>${UNIT_FILE_EXTENSION}, `
621
+ + 'and a name past the file-system limit cannot be written at all',
622
+ });
623
+ }
624
+ // ЗАРЕЗЕРВИРОВАННОЕ ИМЯ (находка 6, найдена двумя ревьюерами независимо). Шаблон велит первым
625
+ // делом положить plan.md и заявляет, что он больше не меняется; единица `plan` его затирает, и
626
+ // сверка «план минус диск» теряет опорный файл. Отказ НАЗЫВАЕТ причину, а не отказывает вообще.
627
+ const reserved = units.filter((u) => unitFileName(u) === PLAN_FILE_NAME);
628
+ if (reserved.length > 0) {
629
+ violations.push({
630
+ rule: 'UNITS',
631
+ detail: `unit "${shown(reserved[0] ?? '', 30)}" is reserved: its file would be ${PLAN_FILE_NAME}, `
632
+ + 'which is the plan the swarm writes FIRST and never rewrites. A unit with that name overwrites it, '
633
+ + 'and the "plan minus disk" completeness check loses the very file it subtracts from. Rename the unit',
634
+ });
635
+ }
636
+ const notSlugs = units.filter((u) => !UNIT_SLUG.test(u) && u.length <= MAX_UNIT_NAME);
637
+ if (notSlugs.length > 0) {
638
+ violations.push({
639
+ rule: 'UNITS',
640
+ detail: `not file names: ${notSlugs.slice(0, 3).map((u) => `"${shown(u, 40)}"`).join(', ')}. `
641
+ + 'A unit must be a slug (lowercase letters, digits, dashes or underscores) — the file name is '
642
+ + `derived from it as <unit>${UNIT_FILE_EXTENSION}, `
643
+ + 'otherwise there is nothing to compare the directory against. Use: - c1-identity',
644
+ });
645
+ }
646
+ }
647
+
648
+ const asmRead = readScalar(lines, 'ASSEMBLY_UNIT');
649
+ const assemblyUnit = asmRead.value;
650
+ if (asmRead.count > 1) {
651
+ violations.push({ rule: 'ASSEMBLY_UNIT', detail: `declared ${asmRead.count} times — keep one value` });
652
+ } else if (asmRead.decorated > 0) {
653
+ violations.push({ rule: 'ASSEMBLY_UNIT', detail: 'the only declaration is decorated (bold, quote or backticks) — the parser reads a bare line. Put it bare: ASSEMBLY_UNIT: assemble-report' });
654
+ } else if (assemblyUnit === null || assemblyUnit === '') {
655
+ violations.push({ rule: 'ASSEMBLY_UNIT', detail: 'no report-assembly unit named — today the fragments are assembled by whoever remembers, and that agent is mortal too. Use: ASSEMBLY_UNIT: assemble-report (and add it to UNITS)' });
656
+ } else if (units.length > 0 && !units.includes(assemblyUnit)) {
657
+ violations.push({ rule: 'ASSEMBLY_UNIT', detail: `"${shown(assemblyUnit)}" is not among the units: assembly is declared but not planned as work` });
658
+ }
659
+
660
+ return { ok: violations.length === 0, outputDir, units, assemblyUnit, violations };
661
+ }