@dzhechkov/harness-core 0.8.38 → 0.8.39

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 (149) hide show
  1. package/.dz-manifest.json +223 -123
  2. package/README.md +97 -2
  3. package/dist/agentdb-index.d.ts +3 -3
  4. package/dist/agentdb-index.js +5 -5
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +4 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/backlog.d.ts +2 -1
  10. package/dist/backlog.d.ts.map +1 -1
  11. package/dist/backlog.js +3 -2
  12. package/dist/backlog.js.map +1 -1
  13. package/dist/brain.d.ts.map +1 -1
  14. package/dist/brain.js +9 -2
  15. package/dist/brain.js.map +1 -1
  16. package/dist/bto-optimize.d.ts.map +1 -1
  17. package/dist/bto-optimize.js +9 -12
  18. package/dist/bto-optimize.js.map +1 -1
  19. package/dist/claim-check.d.ts.map +1 -1
  20. package/dist/claim-check.js +50 -14
  21. package/dist/claim-check.js.map +1 -1
  22. package/dist/experiment-assign.d.ts +110 -0
  23. package/dist/experiment-assign.d.ts.map +1 -0
  24. package/dist/experiment-assign.js +229 -0
  25. package/dist/experiment-assign.js.map +1 -0
  26. package/dist/feature-adr-checkpoints.d.ts +7 -2
  27. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  28. package/dist/feature-adr-checkpoints.js +20 -5
  29. package/dist/feature-adr-checkpoints.js.map +1 -1
  30. package/dist/feature-adr-envelope.d.ts +12 -0
  31. package/dist/feature-adr-envelope.d.ts.map +1 -1
  32. package/dist/feature-adr-envelope.js +12 -1
  33. package/dist/feature-adr-envelope.js.map +1 -1
  34. package/dist/feature-adr-routing.d.ts +4 -0
  35. package/dist/feature-adr-routing.d.ts.map +1 -1
  36. package/dist/feature-adr-routing.js +4 -0
  37. package/dist/feature-adr-routing.js.map +1 -1
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +8 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/ledger-cost-fill.d.ts +58 -0
  43. package/dist/ledger-cost-fill.d.ts.map +1 -0
  44. package/dist/ledger-cost-fill.js +78 -0
  45. package/dist/ledger-cost-fill.js.map +1 -0
  46. package/dist/loop-blobs.generated.js +2 -2
  47. package/dist/loop-blobs.generated.js.map +1 -1
  48. package/dist/mutation-gate.d.ts +29 -0
  49. package/dist/mutation-gate.d.ts.map +1 -1
  50. package/dist/mutation-gate.js +20 -0
  51. package/dist/mutation-gate.js.map +1 -1
  52. package/dist/name-check.d.ts +20 -1
  53. package/dist/name-check.d.ts.map +1 -1
  54. package/dist/name-check.js +42 -1
  55. package/dist/name-check.js.map +1 -1
  56. package/dist/no-stubs.d.ts +10 -0
  57. package/dist/no-stubs.d.ts.map +1 -1
  58. package/dist/no-stubs.js +13 -9
  59. package/dist/no-stubs.js.map +1 -1
  60. package/dist/patterns.d.ts +24 -0
  61. package/dist/patterns.d.ts.map +1 -1
  62. package/dist/patterns.js +49 -9
  63. package/dist/patterns.js.map +1 -1
  64. package/dist/publish-source-scope.d.ts +32 -0
  65. package/dist/publish-source-scope.d.ts.map +1 -0
  66. package/dist/publish-source-scope.js +53 -0
  67. package/dist/publish-source-scope.js.map +1 -0
  68. package/dist/publish.d.ts +5 -0
  69. package/dist/publish.d.ts.map +1 -1
  70. package/dist/publish.js +32 -21
  71. package/dist/publish.js.map +1 -1
  72. package/dist/qe-bridge.d.ts +4 -1
  73. package/dist/qe-bridge.d.ts.map +1 -1
  74. package/dist/qe-bridge.js +21 -11
  75. package/dist/qe-bridge.js.map +1 -1
  76. package/dist/rake-analyzer.d.ts +10 -3
  77. package/dist/rake-analyzer.d.ts.map +1 -1
  78. package/dist/rake-analyzer.js +84 -22
  79. package/dist/rake-analyzer.js.map +1 -1
  80. package/dist/recap.d.ts.map +1 -1
  81. package/dist/recap.js +8 -4
  82. package/dist/recap.js.map +1 -1
  83. package/dist/release.d.ts +2 -1
  84. package/dist/release.d.ts.map +1 -1
  85. package/dist/release.js +18 -4
  86. package/dist/release.js.map +1 -1
  87. package/dist/reqe.d.ts.map +1 -1
  88. package/dist/reqe.js +11 -1
  89. package/dist/reqe.js.map +1 -1
  90. package/dist/run-records.d.ts +18 -4
  91. package/dist/run-records.d.ts.map +1 -1
  92. package/dist/run-records.js +61 -7
  93. package/dist/run-records.js.map +1 -1
  94. package/dist/score.d.ts.map +1 -1
  95. package/dist/score.js +8 -1
  96. package/dist/score.js.map +1 -1
  97. package/dist/sign.d.ts +23 -0
  98. package/dist/sign.d.ts.map +1 -1
  99. package/dist/sign.js +52 -0
  100. package/dist/sign.js.map +1 -1
  101. package/dist/skills-verify.d.ts +8 -5
  102. package/dist/skills-verify.d.ts.map +1 -1
  103. package/dist/skills-verify.js +59 -7
  104. package/dist/skills-verify.js.map +1 -1
  105. package/dist/store-guard-prune.d.ts +22 -0
  106. package/dist/store-guard-prune.d.ts.map +1 -0
  107. package/dist/store-guard-prune.js +54 -0
  108. package/dist/store-guard-prune.js.map +1 -0
  109. package/dist/sweep-failure-classify.d.ts +45 -0
  110. package/dist/sweep-failure-classify.d.ts.map +1 -0
  111. package/dist/sweep-failure-classify.js +90 -0
  112. package/dist/sweep-failure-classify.js.map +1 -0
  113. package/dist/workflow-run-dispatch.d.ts +14 -2
  114. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  115. package/dist/workflow-run-dispatch.js +14 -2
  116. package/dist/workflow-run-dispatch.js.map +1 -1
  117. package/package.json +1 -1
  118. package/sbom.json +372 -122
  119. package/src/agentdb-index.ts +5 -5
  120. package/src/amendment-trace.ts +4 -1
  121. package/src/backlog.ts +3 -2
  122. package/src/brain.ts +8 -2
  123. package/src/bto-optimize.ts +10 -8
  124. package/src/claim-check.ts +48 -12
  125. package/src/experiment-assign.ts +276 -0
  126. package/src/feature-adr-checkpoints.ts +20 -5
  127. package/src/feature-adr-envelope.ts +24 -1
  128. package/src/feature-adr-routing.ts +4 -0
  129. package/src/index.ts +17 -3
  130. package/src/loop-blobs.generated.ts +2 -2
  131. package/src/mutation-gate.ts +59 -0
  132. package/src/name-check.ts +43 -1
  133. package/src/no-stubs.ts +14 -5
  134. package/src/patterns.ts +49 -8
  135. package/src/publish-source-scope.ts +53 -0
  136. package/src/publish.ts +38 -16
  137. package/src/qe-bridge.ts +22 -11
  138. package/src/rake-analyzer.ts +75 -20
  139. package/src/rake-signatures.json +98 -0
  140. package/src/recap.ts +8 -4
  141. package/src/release.ts +18 -4
  142. package/src/reqe.ts +11 -1
  143. package/src/run-records.ts +78 -10
  144. package/src/score.ts +8 -1
  145. package/src/sign.ts +53 -0
  146. package/src/skills-verify.ts +69 -9
  147. package/src/store-guard-prune.ts +81 -0
  148. package/src/sweep-failure-classify.ts +88 -0
  149. package/src/workflow-run-dispatch.ts +14 -2
@@ -6,8 +6,8 @@
6
6
  * the PROPERTY (registration). This module makes the property observable.
7
7
  *
8
8
  * Two layers:
9
- * L1 `scanSkillsLayout` — instant, no Claude session: which names CAN register, plus the three
10
- * layout shapes that produced the 1.2.0 defect.
9
+ * L1 `scanSkillsLayout` — instant, no Claude session: names whose on-disk form passes the
10
+ * static checks, plus known issue shapes.
11
11
  * L2 `parseInitFacts` — the authoritative listing, parsed from the `system/init` event of
12
12
  * + `classifyRegistration` `claude -p --output-format stream-json --verbose`. No model prose.
13
13
  *
@@ -19,13 +19,15 @@ import { basename, isAbsolute, join, resolve } from 'node:path';
19
19
 
20
20
  // ── Layer 1: static layout scan ─────────────────────────────────────
21
21
 
22
- /** The three non-registrable shapes, all observed in the health-advisor 1.2.0 defect. */
22
+ /** The seven static issue shapes this scanner reports. */
23
23
  export type SkillIssueKind =
24
24
  | 'no-skill-md' // a skill dir with no SKILL.md at depth 1 → never registers
25
25
  | 'buried-skill-md' // a SKILL.md at depth >= 2 → the loader does not scan that deep
26
26
  | 'plugin-manifest-trap' // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
27
27
  | 'wildcard-allowed-tools' // `allowed-tools: *` → every tool granted; a FINDING (see below)
28
- | 'empty-allowed-tools'; // `allowed-tools:` with no value → looks restrictive, restricts nothing
28
+ | 'empty-allowed-tools' // `allowed-tools:` with no value → looks restrictive, restricts nothing
29
+ | 'missing-frontmatter-fence' // measured absent from the registry without an opening `---`
30
+ | 'missing-description'; // measured absent from the registry without a non-empty description
29
31
 
30
32
  export interface SkillLayoutFinding {
31
33
  readonly dir: string;
@@ -38,7 +40,10 @@ export interface StaticScan {
38
40
  readonly projectDir: string;
39
41
  readonly skillsRoot: string;
40
42
  readonly exists: boolean;
41
- /** Names that CAN register: a dir with SKILL.md exactly one level deep. */
43
+ /**
44
+ * Bare skill names whose on-disk form passes static layout and measured frontmatter checks.
45
+ * Actual registration is established only by the Layer-2 session listing.
46
+ */
42
47
  readonly registrable: readonly string[];
43
48
  /** Load-blocking problems: these make the verdict FAIL. */
44
49
  readonly findings: readonly SkillLayoutFinding[];
@@ -228,6 +233,39 @@ export function parseAllowedTools(markdown: string): AllowedToolsState {
228
233
  return { state: 'listed', values, wildcard: values.includes('*') };
229
234
  }
230
235
 
236
+ type SkillFrontmatterIssue = 'missing-frontmatter-fence' | 'missing-description';
237
+
238
+ /**
239
+ * Read the registration-relevant frontmatter fields, line by line and only before its closing fence.
240
+ * Scanning the whole file would let `description:` in skill documentation suppress a real finding,
241
+ * the same false-positive/false-negative boundary guarded by `parseAllowedTools` above.
242
+ *
243
+ * The two conditions are measured as absence from the registry, not refusal of invocation. Each
244
+ * measurement used a fresh session, so it also does not establish appearance in the same response
245
+ * without a restart.
246
+ */
247
+ function findSkillFrontmatterIssue(markdown: string): SkillFrontmatterIssue | null {
248
+ const lines = markdown.split(/\r?\n/);
249
+ // NO explicit BOM strip, and that is deliberate: `String.prototype.trim()` already removes U+FEFF
250
+ // (it is <ZWNBSP>, part of the WhiteSpace production), so a BOM-prefixed fence compares equal to
251
+ // a bare one. MEASURED 2026-09-19: adding a strip left the mutant "don't strip" 70/70 green —
252
+ // dead code. The tolerance is pinned by a test instead, so replacing `trim()` with anything
253
+ // stricter goes red rather than silently rejecting every BOM-prefixed skill.
254
+ if (lines[0]?.trim() !== '---') return 'missing-frontmatter-fence';
255
+
256
+ let description: string | null = null;
257
+ for (let i = 1; i < lines.length; i++) {
258
+ const line = lines[i] ?? '';
259
+ if (line.trim() === '---') break;
260
+ const match = /^description:(.*)$/.exec(line);
261
+ if (match) {
262
+ description = match[1] ?? '';
263
+ break;
264
+ }
265
+ }
266
+ return description === null || description.trim() === '' ? 'missing-description' : null;
267
+ }
268
+
231
269
  /**
232
270
  * Privilege findings for one skill directory.
233
271
  *
@@ -426,16 +464,38 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
426
464
  const isPluginContainer = hasPluginManifest(dir);
427
465
  const bucket = isPluginContainer ? advisories : findings;
428
466
 
467
+ // Read under a guard, exactly like scanSkillPrivileges: an unreadable SKILL.md is another
468
+ // check's business, and this scanner's contract is to REPORT findings, never to throw out of
469
+ // the whole sweep over one bad file.
470
+ let frontmatterIssue: SkillFrontmatterIssue | null = null;
471
+ if (registers) {
472
+ try {
473
+ frontmatterIssue = findSkillFrontmatterIssue(readFileSync(join(dir, 'SKILL.md'), 'utf8'));
474
+ } catch {
475
+ frontmatterIssue = null;
476
+ }
477
+ }
478
+ if (frontmatterIssue) {
479
+ findings.push({
480
+ dir: name,
481
+ kind: frontmatterIssue,
482
+ detail: frontmatterIssue === 'missing-frontmatter-fence'
483
+ ? 'SKILL.md has no opening frontmatter fence — measured absent from the registry without it'
484
+ : 'SKILL.md has no non-empty frontmatter description — measured absent from the registry without one',
485
+ });
486
+ }
487
+
429
488
  // Права проверяются у КАЖДОГО навыка с читаемым SKILL.md, включая одно-навыковый плагин: щедрая
430
489
  // выдача не становится безопаснее оттого, что навык лежит в контейнере.
431
490
  if (registers) scanSkillPrivileges(dir, name, findings, advisories);
432
491
 
433
- if (registers && !isPluginContainer) {
434
- registrable.push(name);
435
- } else if (registers && isPluginContainer) {
492
+ if (registers) {
493
+ if (!isPluginContainer && !frontmatterIssue) {
494
+ registrable.push(name);
495
+ }
436
496
  // A single-skill plugin: it has a depth-1 SKILL.md AND a manifest, so it registers NAMESPACED.
437
497
  // Expecting the bare directory name here was a false FAIL (QE5 #2); the container check below
438
- // accepts either form.
498
+ // accepts either form. A frontmatter finding likewise keeps the bare name out of registrable.
439
499
  } else {
440
500
  // A dir with no markdown was never meant to be a skill — advisory, not a failure (QE5 #7).
441
501
  const intended = looksLikeSkillDir(dir);
@@ -0,0 +1,81 @@
1
+ import { sep } from 'node:path';
2
+
3
+ export type StoreGuardPruneBucket = 'stale-temp' | 'live' | 'gone-outside-tmp' | 'unreadable';
4
+
5
+ export interface StoreGuardPruneEntry {
6
+ readonly file: string;
7
+ readonly project: string | null;
8
+ readonly bytes: number;
9
+ readonly bucket: StoreGuardPruneBucket;
10
+ }
11
+
12
+ export interface StoreGuardPrunePlan {
13
+ readonly entries: readonly StoreGuardPruneEntry[];
14
+ readonly counts: Readonly<Record<StoreGuardPruneBucket, number>>;
15
+ readonly reclaimableBytes: number;
16
+ }
17
+
18
+ export interface StoreGuardPruneDeps {
19
+ readonly exists: (path: string) => boolean;
20
+ readonly tmpDirs: readonly string[];
21
+ }
22
+
23
+ /**
24
+ * A temp-root candidate that authorizes deletion must be a REAL directory path, never the filesystem
25
+ * root or an empty string. MEASURED 2026-09-20 (cross-family review, P1): `TMPDIR=/` makes
26
+ * `os.tmpdir()` return the single character `"/"` (node strips a trailing slash only when the path is
27
+ * longer than one character), and `isUnder` then matches EVERY absolute path — so every
28
+ * `gone-outside-tmp` mark, which FR-3 exists to protect, would be classified `stale-temp` and deleted.
29
+ * A degenerate candidate authorizes nothing and is dropped here, in the pure half, where it is testable.
30
+ */
31
+ function isUsableTmpDir(tmpDir: string): boolean {
32
+ const trimmed = tmpDir.trim();
33
+ return trimmed.length > 1 && trimmed !== sep && trimmed !== '/';
34
+ }
35
+
36
+ function isUnder(project: string, tmpDir: string): boolean {
37
+ const prefix = tmpDir.endsWith(sep) ? tmpDir : `${tmpDir}${sep}`;
38
+ return project === tmpDir || project.startsWith(prefix);
39
+ }
40
+
41
+ export function planStoreGuardPrune(
42
+ input: readonly { readonly file: string; readonly bytes: number; readonly text: string }[],
43
+ deps: StoreGuardPruneDeps,
44
+ ): StoreGuardPrunePlan {
45
+ const counts: Record<StoreGuardPruneBucket, number> = {
46
+ 'stale-temp': 0,
47
+ live: 0,
48
+ 'gone-outside-tmp': 0,
49
+ unreadable: 0,
50
+ };
51
+ let reclaimableBytes = 0;
52
+
53
+ const entries = input.map(({ file, bytes, text }): StoreGuardPruneEntry => {
54
+ let value: unknown;
55
+ try {
56
+ value = JSON.parse(text);
57
+ } catch {
58
+ counts.unreadable += 1;
59
+ return { file, project: null, bytes, bucket: 'unreadable' };
60
+ }
61
+
62
+ if (typeof value !== 'object' || value === null
63
+ || typeof (value as { project?: unknown }).project !== 'string'
64
+ || (value as { project: string }).project.length === 0) {
65
+ counts.unreadable += 1;
66
+ return { file, project: null, bytes, bucket: 'unreadable' };
67
+ }
68
+
69
+ const project = (value as { project: string }).project;
70
+ const bucket: StoreGuardPruneBucket = deps.exists(project)
71
+ ? 'live'
72
+ : deps.tmpDirs.filter(isUsableTmpDir).some((tmpDir) => isUnder(project, tmpDir))
73
+ ? 'stale-temp'
74
+ : 'gone-outside-tmp';
75
+ counts[bucket] += 1;
76
+ if (bucket === 'stale-temp') reclaimableBytes += bytes;
77
+ return { file, project, bytes, bucket };
78
+ });
79
+
80
+ return { entries, counts, reclaimableBytes };
81
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Различитель красных обхода: ЛОГИЧЕСКИЙ отказ против отказа ПОД НАГРУЗКОЙ.
3
+ *
4
+ * ЗАЧЕМ. Запись 9e48a975 установила класс: набор harness-core детерминирован В ОДИНОЧКУ и
5
+ * краснеет ТОЛЬКО под параллельной нагрузкой (пять последовательных прогонов — ноль красных;
6
+ * те же тесты под фоновым вторым набором — красные поодиночке и в разных сочетаниях).
7
+ * Подпись класса: тест (а) читает собранный `dist`, (б) порождает процессы, (в) несёт явный
8
+ * таймаут, близкий к измеренному времени.
9
+ *
10
+ * ИЗМЕРЕНО 2026-09-19, новый экземпляр того же класса: `scout` прошёл в одном обходе и упал в
11
+ * следующем через 17 минут — `deep-analyzer.test.ts`, `Test timed out in 5000ms`, 192 из 193
12
+ * зелёные; тот же файл в одиночку 5 из 5. Единственное различие между прогонами — загрузка
13
+ * машины (подкачка 2342 → 2671 МБ).
14
+ *
15
+ * Класс диагностирован, но МЕХАНИЗМА не имел: каждый экземпляр стоил ручного разбора. Этот
16
+ * модуль — чистая половина механизма: он извлекает из журнала обхода ИМЕНА упавших файлов и
17
+ * классифицирует результат ПЕРЕПРОГОНА В ОДИНОЧКУ. Запуск живёт в скрипте, здесь его нет.
18
+ *
19
+ * ТРЁХЗНАЧНОСТЬ ОБЯЗАТЕЛЬНА. Перепрогон, который не удалось выполнить, — это НЕ «прошёл»;
20
+ * прибор, который не смог посмотреть, не имеет права на вердикт.
21
+ */
22
+
23
+ /** Вердикт перепрогона одного файла в одиночку. */
24
+ export type RerunVerdict = 'does-not-reproduce-alone' | 'reproduces-alone' | 'inconclusive';
25
+
26
+ /**
27
+ * Имена упавших тестовых файлов из журнала набора. Понимает ОБА формата, которые даёт обход:
28
+ * vitest ` FAIL test/x.test.ts > describe > it`
29
+ * node:test ` location: '/abs/.../test/x.test.mjs:95:1'`
30
+ *
31
+ * Пути приводятся к виду, относительному каталогу пакета, потому что перепрогон запускается
32
+ * из него. Порядок сохраняется, дубликаты убираются: один файл перепрогоняется один раз.
33
+ */
34
+ export function parseFailedTestFiles(logText: string, packageDir?: string): string[] {
35
+ const out: string[] = [];
36
+ const seen = new Set<string>();
37
+ const add = (raw: string): void => {
38
+ let p = raw.trim();
39
+ if (packageDir !== undefined && p.startsWith(packageDir)) {
40
+ p = p.slice(packageDir.length).replace(/^\/+/, '');
41
+ }
42
+ // Абсолютный путь к ЧУЖОМУ пакету перепрогонять нельзя: он не относителен нашему каталогу.
43
+ if (p.startsWith('/')) return;
44
+ if (p === '' || seen.has(p)) return;
45
+ seen.add(p);
46
+ out.push(p);
47
+ };
48
+ for (const line of logText.split('\n')) {
49
+ const vitest = /^\s*FAIL\s+(?:\|[^|]*\|\s*)?(\S+\.(?:test|spec)\.[cm]?[jt]s)\b/.exec(line);
50
+ if (vitest?.[1] !== undefined) { add(vitest[1]); continue; }
51
+ const node = /location:\s*'([^']+\.(?:test|spec)\.[cm]?[jt]s):\d+/.exec(line);
52
+ if (node?.[1] !== undefined) { add(node[1]); }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Вердикт по исходу перепрогона.
59
+ *
60
+ * @param sweepFailed упал ли файл в обходе (иначе классифицировать нечего)
61
+ * @param rerun результат одиночного прогона, или null если запустить не удалось
62
+ */
63
+ export function classifyRerun(
64
+ sweepFailed: boolean,
65
+ rerun: { readonly exitCode: number; readonly ranAnyTest: boolean } | null,
66
+ ): RerunVerdict {
67
+ if (!sweepFailed) return 'inconclusive';
68
+ if (rerun === null) return 'inconclusive';
69
+ // «Ни одного теста не запустилось» — это не зелёный прогон, а несостоявшаяся проба: так
70
+ // выглядит и опечатка в пути, и набор, который не грузится. Читать это как «прошёл» значит
71
+ // объявить файл нагрузочно-зависимым на основании того, что его никто не проверил.
72
+ if (!rerun.ranAnyTest) return 'inconclusive';
73
+ return rerun.exitCode === 0 ? 'does-not-reproduce-alone' : 'reproduces-alone';
74
+ }
75
+
76
+ /** Человеческая расшифровка вердикта — одна строка, называющая, что делать дальше. */
77
+ export function describeVerdict(verdict: RerunVerdict): string {
78
+ if (verdict === 'does-not-reproduce-alone') {
79
+ // НЕ «зависит от нагрузки»: у зелёного одиночного прогона есть ДВЕ причины, и различить их
80
+ // перепрогоном нельзя — нагрузка обхода ЛИБО починка, внесённая уже после него. Называть
81
+ // одну из двух значило бы выдать догадку за измерение.
82
+ return 'в одиночку ЗЕЛЁНЫЙ — либо отказ зависел от нагрузки обхода, либо починка легла после него; перепрогон эти две причины не различает';
83
+ }
84
+ if (verdict === 'reproduces-alone') {
85
+ return 'в одиночку ТОЖЕ красный — настоящий дефект, нагрузка ни при чём';
86
+ }
87
+ return 'НЕ УСТАНОВЛЕНО — перепрогон не состоялся; это не «прошёл»';
88
+ }
@@ -11,6 +11,7 @@
11
11
  * a convention whose reason is lost is the next thing somebody "cleans up".
12
12
  */
13
13
 
14
+ import { CODEX_EXEC_PROMPT_CEILING_CHARS } from './feature-adr-routing.js';
14
15
  import type { Deliverable } from './loop-plan.js';
15
16
  import { claudeProbeArgs, claudeReviewArgs, extractClaudeResult, interpretClaudeProbe, type BridgeFamily } from './qe-bridge.js';
16
17
  import type { WfRunReason } from './workflow-run.js';
@@ -149,13 +150,24 @@ export const CODEX_SCOPING_PREFIX =
149
150
  'Answer directly from this prompt text alone; no commands, no files, no tools.';
150
151
 
151
152
  /**
152
- * The REAL prompt ceiling for `codex exec`.
153
+ * The REAL prompt ceiling for `codex exec` — RE-EXPORTED, never re-declared.
153
154
  *
154
155
  * The folk value 1200 is refuted history: it came from an era when the prompt travelled through a
155
156
  * fire-and-forget wrapper. Over-ceiling ⇒ a LOUD `prompt-over-ceiling`, never truncation — a
156
157
  * truncated prompt produces a confident answer to a question nobody asked.
158
+ *
159
+ * ONE NUMBER, ONE DEFINITION. Until 2026-09-20 this module declared its own `= 24_000` beside the
160
+ * one in `feature-adr-routing.ts`, and NOTHING compared them: no test imported both, so the two
161
+ * could diverge in silence and `codexExecPlan` would route to Claude at a different threshold than
162
+ * `makeCodexExecDispatcher` rejects at. Measured: `feature-adr-codex-dispatch.test.ts` asserts only
163
+ * `> 4000`, and the mirror guard in `codex-scoped-review.test.ts` ties routing.ts to the four
164
+ * workflow copies but knows nothing about this file.
165
+ *
166
+ * The definition lives in `feature-adr-routing.ts` and not here because THAT module is lifted
167
+ * verbatim into the workflow sandbox by `scripts/gen-loop-blobs.mjs` and therefore may not import
168
+ * anything. The dependency can only point this way.
157
169
  */
158
- export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
170
+ export { CODEX_EXEC_PROMPT_CEILING_CHARS };
159
171
 
160
172
  /**
161
173
  * argv for one codex dispatch. The prompt travels as ONE argv element (no shell, no quoting), and