From 0d5d621fdcd10136ba0bfc5b5ada91941e41fa77 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 05:57:46 +0400 Subject: [PATCH 01/17] =?UTF-8?q?docs(openspec):=20change=20dsh-prestep=20?= =?UTF-8?q?=E2=80=94=20proposal=20=D0=B8=20design=20(#43,=20#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/.openspec.yaml | 2 + openspec/changes/dsh-prestep/design.md | 73 +++++++++++++++++++++ openspec/changes/dsh-prestep/proposal.md | 60 +++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 openspec/changes/dsh-prestep/.openspec.yaml create mode 100644 openspec/changes/dsh-prestep/design.md create mode 100644 openspec/changes/dsh-prestep/proposal.md diff --git a/openspec/changes/dsh-prestep/.openspec.yaml b/openspec/changes/dsh-prestep/.openspec.yaml new file mode 100644 index 0000000..7ccf7c3 --- /dev/null +++ b/openspec/changes/dsh-prestep/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/dsh-prestep/design.md b/openspec/changes/dsh-prestep/design.md new file mode 100644 index 0000000..1c0cdfa --- /dev/null +++ b/openspec/changes/dsh-prestep/design.md @@ -0,0 +1,73 @@ +# Design: dsh-prestep — harness-check и role-hint через agent/pre-step + +Issue: #43 (+#62). Статус: черновик до builder-check; утверждение — +`/approve dsh-prestep design`. + +## Контекст + +CC-хуки harness-check/role-hint — SessionStart; в DSH аналог — инъекции +`agent/pre-step` (уже используем для superpowers-bootstrap). Гейты #59 +живые: этот change идёт первым по гейтованному циклу (acceptance #59). + +## Механика + +Обе инъекции — раз за сессию, с маркером `<-- DSH-HARNESS-PRESTEP:...>` +и тем же механизмом видимости, что у bootstrap (`isBootstrapVisible` +переименован в общий `isInjectionVisible(agent, messages, marker)`). +Порядок в pre-step: другие инъекции → harness-check → role-hint (обе +неблокирующие; reject/abort — пропуск; сабагенты — пропуск, как +bootstrap). + +### harness-check + +- Сигнал подключения: `CLAUDE.md` или `AGENTS.md` в корне cwd содержит + `OpenSpec и superpowers` ИЛИ существует `openspec/` ИЛИ + `.claude/code-paths` ИЛИ `CLAUDE.harness-section.md`. Нет — инъекция: + «[claude-harness] Проект не подключён к обвязке: предложи владельцу + `adopt-project.sh` или спроси, нужна ли обвязка». Не блокирует. +- cwd — `sessionCwd` (та же резолюция, что у гейтов: workingDirectory → + process.cwd()). + +### role-hint + +- Триггер: первое пользовательское сообщение сессии (в `decision.messages` + ищем message с `source.kind === 'user'` и текстом; до первого такого — + инъекции нет). +- Ключевые слова (порядок приоритета, как в AGENTS.md): + - admin: `jira`, `грант`, `гант`, `спринт`; + - qa: `стенд`, `прогон`, `скриншот`, `макет`; + - analyst: `тз`, `oq`, `спека`, `методика`; + - dev: `срез`, `issue`, `pr`, `тест`, `миграция`. + Совпадение — инъекция `[claude-harness] Роль сессии: . Вызови + skill role. Если не так — скажи.` Нет — инъекция вопроса с четырьмя + вариантами (текст — как в AGENTS.md). +- Матчинг: нижний регистр, подстроки по границам слов (русские слова — + подстрока в нижнем регистре; `pr` — только как отдельное слово). + +## Decisions made without discussion + +| # | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| D-1 | Роль определяется по первому сообщению (не по заголовку — его в DSH нет) | Ровно то правило AGENTS.md, которое модель сейчас исполняет «по памяти»; переносим в технику | Ложная подсказка роли при неоднозначном сообщении — поправима (`/role`), не блокирует | +| D-2 | harness-check — только подсказка, без HARNESS_AUTO_ADOPT | YAGNI; авто-подключение — отдельная фича при запросе | Сессия в неподключённом проекте продолжает работу — как и сейчас | +| D-3 | Общий хелпер инъекций (рефакторинг bootstrap) без изменения поведения bootstrap | Два новых pre-step не должны дублировать маркер/дедуп | Регрессия bootstrap — страхуется существующими тестами bootstrap | +| D-4 | #62-пункты едут задачами этого change (не отдельными фичами) | Мелкие, один PR-цикл дешевле трёх | Смешивание тем в одном PR — компенсируется отдельными задачами плана | +| D-5 | Конфиг-ключи harnessCheck/roleHint — по умолчанию true, гейтятся bootstrapOk (нужен ctx.on) | Симметрия с bootstrap; без pre-step обе инъекции невозможны | Нет | + +## Тесты (идеи для плана) + +- pure: detectAdoption(files), detectRole(firstUserText) → role|null, + buildRoleHintText(role), buildHarnessCheckText(), маркер-дедуп; +- wiring: apply с fake ctx — инъекции появляются при наличии + пользовательского сообщения, один раз, пропускаются для сабагентов; +- T2.1: негативный тест openspec — PATH-изоляция (временный каталог без + openspec-бинарника); +- O-2: install.sh WARN при DSH_HOME→существующий каталог + нестандартный + HOME (тест в install.test.mjs); +- T3.4: пин текста в sdd-скилле («шаг „тест падает“ обязателен»). + +## На потом (в план) + +- Разбивка: Task 1 prestep.js (harness-check+role-hint pure) → Task 2 + wiring+bootstrap-рефакторинг → Task 3 #62 (T2.1/O-2/T3.4) → Task 4 + спека-дельта `session-prestep` + доки. diff --git a/openspec/changes/dsh-prestep/proposal.md b/openspec/changes/dsh-prestep/proposal.md new file mode 100644 index 0000000..e9befe4 --- /dev/null +++ b/openspec/changes/dsh-prestep/proposal.md @@ -0,0 +1,60 @@ +# Proposal: dsh-prestep — harness-check и role-hint через agent/pre-step + +Issue: #43 (+#62 follow-ups). Источники: роадмап #48 (закрыт), дизайн #69, +AGENTS.md (правило первого сообщения), архив #42 (§6 приёмка). + +## Why + +У Claude Code два SessionStart-хука: `harness-check.sh` (проект не подключён +→ предложить adopt) и `role-hint.sh` (подсказка роли по заголовку сессии). +В DSH их нет (#43): сессия в неподключённом проекте молча работает без +обвязки, а выбор роли держится только на дисциплине модели (первое правило +AGENTS.md). Плюс три мелких follow-ups из #42 (#62: T2.1 — PATH в негативном +тесте, T3.4 — TDD-порядок в брифах, O-2 — изоляция HOME/DSH_HOME в брифах). + +## What Changes + +1. **harness-check как pre-step**: один раз за сессию (маркер-дедуп, как у + bootstrap) проверяем, что проект подключён (в `CLAUDE.md`/`AGENTS.md` + корня cwd есть секция обвязки «OpenSpec и superpowers» или файлы + `openspec/`/`.claude/code-paths`); нет — неблокирующая инъекция: + «проект не подключён к обвязке; предложи `adopt-project.sh` или спроси + владельца». Конфиг `harnessCheck: true`. +2. **role-hint как pre-step**: на первом пользовательском сообщении — + ключевые слова из AGENTS.md (Jira/Гант/спринт → admin; стенд/прогон/ + скриншот/макет → qa; ТЗ/OQ/спека/методика → analyst; срез/issue/PR/ + тест/миграция → dev) → инъекция `[claude-harness] Роль сессии: ` с + указанием вызвать `skill role`; нет совпадения — инъекция вопроса с + четырьмя вариантами. Один раз за сессию. Конфиг `roleHint: true`. + Заголовка сессии в DSH нет — сигнал берём из первого сообщения + (ровно то правило, что уже написано в AGENTS.md). +3. **Follow-ups #62** (задачами этого же change): + - T2.1/M-2: негативный тест `--check` без openspec — изоляция PATH + вместо фиксированного `bin:/usr/bin:…`; + - T3.4: sdd-skill и шаблоны брифов — «шаг „тест падает“ обязателен до + кода» формулируется жёстче; + - O-2: брифы/рецепты изолируют и HOME, и DSH_HOME; `install.sh` + предупреждает, когда DSH_HOME указывает на существующий каталог при + нестандартном HOME. +4. Обе инъекции используют общую маркер/видимость-машинерию bootstrap + (выносим хелперы, поведение bootstrap не меняется). + +## Impact + +- Affected: `dsh/bundle/lib/prestep.js` (новое), `dsh/bundle/lib/bootstrap.js` + (рефакторинг хелперов), `dsh/bundle/index.js` (wiring, аддитивно), + `dsh/skills/sdd/SKILL.md`, `dsh/install.sh` (WARN O-2), + `dsh/tests/openspec.test.mjs` (T2.1), тесты prestep, спека-дельта + (capability `session-prestep`), README/PORT. +- Не трогаем: CC-сторону (хуки harness-check.sh/role-hint.sh остаются), + гейты, bootstrap-инъекцию. +- Breaking: нет (обе инъекции — неблокирующие; конфиг отключает). + +## Non-goals + +HARNESS_AUTO_ADOPT (авто-подключение) — потом, при запросе; портирование +`briefing` (#45) — отдельно; анализ заголовка сессии (в DSH его нет). + +## Открытые вопросы + +Нет — решения в design.md; владелец утверждает через `/approve dsh-prestep design`. -- 2.47.2 From 5f2a914de04f907867b83e133c9c99b2996694d1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 06:05:14 +0400 Subject: [PATCH 02/17] =?UTF-8?q?docs(openspec):=20dsh-prestep=20=E2=80=94?= =?UTF-8?q?=20builder-check=20=D0=BE=D1=82=D1=87=D1=91=D1=82,=20=D0=B0?= =?UTF-8?q?=D0=B4=D1=8C=D1=8E=D0=B4=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD=D0=B5=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/builder-check.md | 19 +++++++++ openspec/changes/dsh-prestep/design.md | 42 ++++++++++++------- 2 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/dsh-prestep/builder-check.md diff --git a/openspec/changes/dsh-prestep/builder-check.md b/openspec/changes/dsh-prestep/builder-check.md new file mode 100644 index 0000000..6229ff5 --- /dev/null +++ b/openspec/changes/dsh-prestep/builder-check.md @@ -0,0 +1,19 @@ +# Builder-check: dsh-prestep + +Дата: 25.10.2026. Свежий субагент (v4-pro). Три вопроса — все адьюдицированы +контроллером в design.md: + +1. Триггер role-hint видит собственные инъекции (bootstrap/harness-check — + user-source сообщения) → добавлен фильтр: сообщение не считается первым, + если начинается с `` (формат bootstrap, не `<--`); `isBootstrapVisible` → общий `isInjectionVisible` (переименование трогает imports bootstrap.test.mjs — учтено в плане) | Одна машинерия видимости на все инъекции | Регрессия bootstrap — страхуется его тестами | +| D-7 | Текст вопроса с четырьмя вариантами сочинён в этом дизайне (в репо его не было) | Builder-check подтвердил отсутствие | Нет | +| D-8 | T3.4: в sdd-скилле предложения про TDD-порядок нет — текст добавляется с нуля (пин в тесте), а не «усиливается» | Факт builder-check | Нет | ## Тесты (идеи для плана) -- 2.47.2 From fd74570a84937766baa0e0e7d8752ad7c4eed349 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 06:06:02 +0400 Subject: [PATCH 03/17] =?UTF-8?q?docs(openspec):=20dsh-prestep=20=E2=80=94?= =?UTF-8?q?=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=D1=8B=20harness-check?= =?UTF-8?q?=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=20CC-=D1=85=D1=83=D0=BA=D0=BE=D0=BC=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/design.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/openspec/changes/dsh-prestep/design.md b/openspec/changes/dsh-prestep/design.md index 845d2dc..2850fb5 100644 --- a/openspec/changes/dsh-prestep/design.md +++ b/openspec/changes/dsh-prestep/design.md @@ -20,14 +20,15 @@ bootstrap). ### harness-check -- Сигнал подключения: upward-walk от sessionCwd до корня ФС: ближайший - каталог с `.git` (или без него до упора) — «корень проекта»; в нём - `CLAUDE.md`/`AGENTS.md` содержит `OpenSpec и superpowers` ИЛИ есть - `openspec/` ИЛИ `.claude/code-paths` ИЛИ `CLAUDE.harness-section.md`. - Вне git-репозитория (`.git` не найден до корня) — пропуск, как у - CC-хука (молчит). Нет сигнала — инъекция: - «[claude-harness] Проект не подключён к обвязке: предложи владельцу - `adopt-project.sh` или спроси, нужна ли обвязка». Не блокирует. +- Сигнал подключения (как у CC-хука + DSH-специфичный): upward-walk от + sessionCwd до корня ФС: ближайший каталог с `.git` (или без него до + упора) — «корень проекта»; сигналы: `openspec/`, `.codegraph/`, + секция `## OpenSpec и superpowers` в `CLAUDE.md`, DSH-бонус — + `.claude/code-paths`. Всё на месте — молчим. Вне git-репозитория + (`.git` не найден до корня) — пропуск, как у CC-хука. Чего-то нет — + инъекция с перечнем отсутствующего и командой + `bash /adopt-project.sh ` (неблокирующая, в формате + сообщения CC-хука). HARNESS_AUTO_ADOPT — вне скоупа (D-2). - Реализация — fs-чтения (stat/readFile) по путям, без spawn git. ### role-hint -- 2.47.2 From 459bd400c9dd58d8da775fb2d13ebf3c4255a30f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 10:30:44 +0400 Subject: [PATCH 04/17] =?UTF-8?q?docs(openspec):=20dsh-prestep=20=E2=80=94?= =?UTF-8?q?=20DSH-=D0=B0=D0=B2=D1=82=D0=BE=D0=BD=D0=BE=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C:=20=D0=B1=D0=B5=D0=B7=20CC-=D0=BF=D1=83?= =?UTF-8?q?=D1=82=D0=B5=D0=B9/adopt-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=D0=B0=20(D-9,=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86?= =?UTF-8?q?=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/design.md | 20 ++++++++++++-------- openspec/changes/dsh-prestep/proposal.md | 18 +++++++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/openspec/changes/dsh-prestep/design.md b/openspec/changes/dsh-prestep/design.md index 2850fb5..c488118 100644 --- a/openspec/changes/dsh-prestep/design.md +++ b/openspec/changes/dsh-prestep/design.md @@ -11,7 +11,7 @@ CC-хуки harness-check/role-hint — SessionStart; в DSH аналог — и ## Механика -Обе инъекции — раз за сессию, с маркером `<-- DSH-HARNESS-PRESTEP:...>` +Обе инъекции — раз за сессию, с маркером `` и тем же механизмом видимости, что у bootstrap (`isBootstrapVisible` переименован в общий `isInjectionVisible(agent, messages, marker)`). Порядок в pre-step: другие инъекции → harness-check → role-hint (обе @@ -20,15 +20,18 @@ bootstrap). ### harness-check -- Сигнал подключения (как у CC-хука + DSH-специфичный): upward-walk от +- Сигнал подключения (проектные, без CC-артефактов): upward-walk от sessionCwd до корня ФС: ближайший каталог с `.git` (или без него до упора) — «корень проекта»; сигналы: `openspec/`, `.codegraph/`, - секция `## OpenSpec и superpowers` в `CLAUDE.md`, DSH-бонус — - `.claude/code-paths`. Всё на месте — молчим. Вне git-репозитория - (`.git` не найден до корня) — пропуск, как у CC-хука. Чего-то нет — - инъекция с перечнем отсутствующего и командой - `bash /adopt-project.sh ` (неблокирующая, в формате - сообщения CC-хука). HARNESS_AUTO_ADOPT — вне скоупа (D-2). + секция `## OpenSpec и superpowers` в `CLAUDE.md`. Всё на месте — + молчим. Вне git-репозитория (`.git` не найден до корня) — пропуск. + Чего-то нет — неблокирующая инъекция: + «[claude-harness] Проект не подключён к обвязке: нет: … . + Предложи владельцу настройку: `openspec init --tools agents`, + `codegraph init` и секцию «OpenSpec и superpowers» в CLAUDE.md.» + (DSH-нативный текст: без `~/.claude`-путей и adopt-скрипта CC — их + порт под DSH — отдельная фича, D-9). HARNESS_AUTO_ADOPT — вне скоупа + (D-2). - Реализация — fs-чтения (stat/readFile) по путям, без spawn git. ### role-hint @@ -66,6 +69,7 @@ bootstrap). | D-6 | Маркер инъекций — `` (формат bootstrap, не `<--`); `isBootstrapVisible` → общий `isInjectionVisible` (переименование трогает imports bootstrap.test.mjs — учтено в плане) | Одна машинерия видимости на все инъекции | Регрессия bootstrap — страхуется его тестами | | D-7 | Текст вопроса с четырьмя вариантами сочинён в этом дизайне (в репо его не было) | Builder-check подтвердил отсутствие | Нет | | D-8 | T3.4: в sdd-скилле предложения про TDD-порядок нет — текст добавляется с нуля (пин в тесте), а не «усиливается» | Факт builder-check | Нет | +| D-9 | DSH-автономность (замечание владельца 25.10): в текстах и сигналах дизайна нет CC-артефактов — ни `~/.claude`-путей, ни `adopt-project.sh`, ни `.claude/code-paths`; CC-хуки harness-check.sh/role-hint.sh живут только для CC-сессий; DSH-порт adopt-скрипта — отдельная фича (non-goal этого change) | Порт должен жить самостоятельно; CC-сторона — параллельный мир | Подключение проекта в DSH остаётся ручным до порта adopt-скрипта — инъекция даёт точный список шагов | ## Тесты (идеи для плана) diff --git a/openspec/changes/dsh-prestep/proposal.md b/openspec/changes/dsh-prestep/proposal.md index e9befe4..654ec06 100644 --- a/openspec/changes/dsh-prestep/proposal.md +++ b/openspec/changes/dsh-prestep/proposal.md @@ -15,11 +15,13 @@ AGENTS.md). Плюс три мелких follow-ups из #42 (#62: T2.1 — PATH ## What Changes 1. **harness-check как pre-step**: один раз за сессию (маркер-дедуп, как у - bootstrap) проверяем, что проект подключён (в `CLAUDE.md`/`AGENTS.md` - корня cwd есть секция обвязки «OpenSpec и superpowers» или файлы - `openspec/`/`.claude/code-paths`); нет — неблокирующая инъекция: - «проект не подключён к обвязке; предложи `adopt-project.sh` или спроси - владельца». Конфиг `harnessCheck: true`. + bootstrap) проверяем, что проект подключён (upward-walk до корня + git-проекта; сигналы — `openspec/`, `.codegraph/`, секция «OpenSpec и + superpowers» в `CLAUDE.md`); нет — неблокирующая DSH-нативная + инъекция со списком отсутствующего и шагами настройки + (`openspec init --tools agents`, `codegraph init`, секция в + `CLAUDE.md`). Без `~/.claude`-путей и CC-adopt-скрипта (D-9). + Конфиг `harnessCheck: true`. 2. **role-hint как pre-step**: на первом пользовательском сообщении — ключевые слова из AGENTS.md (Jira/Гант/спринт → admin; стенд/прогон/ скриншот/макет → qa; ТЗ/OQ/спека/методика → analyst; срез/issue/PR/ @@ -52,8 +54,10 @@ AGENTS.md). Плюс три мелких follow-ups из #42 (#62: T2.1 — PATH ## Non-goals -HARNESS_AUTO_ADOPT (авто-подключение) — потом, при запросе; портирование -`briefing` (#45) — отдельно; анализ заголовка сессии (в DSH его нет). +HARNESS_AUTO_ADOPT (авто-подключение) — потом, при запросе; порт +`adopt-project.sh` под DSH — отдельная фича (D-9, замечание владельца о +CC-независимости); портирование `briefing` (#45) — отдельно; анализ +заголовка сессии (в DSH его нет). ## Открытые вопросы -- 2.47.2 From c856af4ceb6ada2897519923817893fb817a6c99 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 10:31:16 +0400 Subject: [PATCH 05/17] =?UTF-8?q?docs(openspec):=20dsh-prestep=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B5=D1=84=D0=B8=D0=BA=D1=81=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=8A=D0=B5=D0=BA=D1=86=D0=B8=D0=B9=20[dsh-harness]=20(D-10,?= =?UTF-8?q?=20DSH-=D0=B0=D0=B2=D1=82=D0=BE=D0=BD=D0=BE=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/design.md | 7 ++++--- openspec/changes/dsh-prestep/proposal.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openspec/changes/dsh-prestep/design.md b/openspec/changes/dsh-prestep/design.md index c488118..d7c69c8 100644 --- a/openspec/changes/dsh-prestep/design.md +++ b/openspec/changes/dsh-prestep/design.md @@ -26,7 +26,7 @@ bootstrap). секция `## OpenSpec и superpowers` в `CLAUDE.md`. Всё на месте — молчим. Вне git-репозитория (`.git` не найден до корня) — пропуск. Чего-то нет — неблокирующая инъекция: - «[claude-harness] Проект не подключён к обвязке: нет: … . + «[dsh-harness] Проект не подключён к обвязке: нет: … . Предложи владельцу настройку: `openspec init --tools agents`, `codegraph init` и секцию «OpenSpec и superpowers» в CLAUDE.md.» (DSH-нативный текст: без `~/.claude`-путей и adopt-скрипта CC — их @@ -38,7 +38,7 @@ bootstrap). - Триггер: **новое** пользовательское сообщение текущего pre-step (`decision.messages`), source.kind === 'user', И текст не начинается с - ``; общая видимость через isInjectionVisible. +- Роль-ключевики ровно из AGENTS.md (admin: jira, гант, спринт; qa: стенд, прогон, скриншот, макет; analyst: тз, oq, спека, методика; dev: срез, issue, pr, тест, миграция); `pr` — отдельным словом. +- Вопрос с четырьмя вариантами — текст из design.md D-7. +- Префикс инъекций `[dsh-harness]` (D-10; фильтр знает и `[claude-harness]`). +- CC-сторона (hooks/) не трогается. + +## Review Focus +1. Инъекции бандла не принимаются за первое пользовательское сообщение (фильтр маркеров). +2. Автономный раунд без нового сообщения — role-hint не стреляет. +3. cwd вне git-репозитория — harness-check молчит. +4. Подкаталог репозитория (cwd = /dsh) — upward-walk находит корень, ложной подсказки нет. +5. bootstrap:false — инъекции живут (гейт на ctx.on, не на bootstrapOk). + +--- + +### Task 1: prestep.js — чистые функции + +**Files:** Create `dsh/bundle/lib/prestep.js`, `dsh/tests/prestep.test.mjs`. + +**Interfaces:** +- `detectAdoption(cwd) -> {adopted:boolean, root:string|null, missing:string[]}` — upward-walk: ближайший каталог с `.git` (или root ФС); сигналы: `openspec/`, `.codegraph/`, `## OpenSpec и superpowers` в `CLAUDE.md` (без `.claude/code-paths`, D-9); вне git → `{adopted:true, missing:[]}` (молчим). +- `buildHarnessCheckText(root, missing) -> string` — DSH-нативный текст (D-9): `[dsh-harness] Проект не подключён к обвязке: нет: … . Предложи владельцу настройку: openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в CLAUDE.md.`. +- `detectRole(firstUserText) -> 'dev'|'admin'|'analyst'|'qa'|null`. +- `buildRoleHintText(role|null) -> string` — `[dsh-harness] Роль сессии: . Вызови skill role. Если не так — скажи.` или вопрос с четырьмя вариантами (D-7). +- `isHarnessMessage(text) -> boolean` — текст начинается с `` + return { marker, body: `${marker}\n${value}` } +} diff --git a/dsh/tests/prestep.test.mjs b/dsh/tests/prestep.test.mjs new file mode 100644 index 0000000..5b013aa --- /dev/null +++ b/dsh/tests/prestep.test.mjs @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import test from 'node:test' +import { + buildHarnessCheckText, + buildRoleHintText, + detectAdoption, + detectRole, + isHarnessMessage, + makePrestepMarker, +} from '../bundle/lib/prestep.js' +import { cleanup, tempDir, write } from './helpers.mjs' + +const SECTION = '## OpenSpec и superpowers\n\nСекция подключения.\n' + +/** + * A throwaway directory tree that looks like a harness-adopted repository: + * `.git/`, `openspec/`, `.codegraph/`, and the CLAUDE.md section. + */ +function adoptedRepo() { + const repo = tempDir('dsh-harness-adopted-') + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, 'openspec', 'specs', '.keep'), '') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), `# Проект\n\n${SECTION}`) + return repo +} + +// --------------------------------------------------------------------------- +// detectAdoption +// --------------------------------------------------------------------------- + +test('detectAdoption: adopted repo reports adopted with no missing signals', () => { + const repo = adoptedRepo() + try { + assert.deepEqual(detectAdoption(repo), { adopted: true, root: repo, missing: [] }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: repo without openspec/ and the CLAUDE.md section is not adopted', () => { + const repo = tempDir('dsh-harness-partial-') + try { + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), '# Проект без секции\n') + + assert.deepEqual(detectAdoption(repo), { + adopted: false, + root: repo, + missing: ['openspec/', 'секция «OpenSpec и superpowers» в CLAUDE.md'], + }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: cwd in a subdirectory walks up to the repo root', () => { + const repo = adoptedRepo() + try { + const subdir = join(repo, 'dsh', 'bundle', 'lib') + write(join(subdir, 'module.js'), 'export {}\n') + + assert.deepEqual(detectAdoption(subdir), { adopted: true, root: repo, missing: [] }) + } finally { + cleanup(repo) + } +}) + +test('detectAdoption: cwd outside any git repo stays silent', () => { + const outside = tempDir('dsh-harness-outside-') + try { + assert.deepEqual(detectAdoption(outside), { adopted: true, root: null, missing: [] }) + } finally { + cleanup(outside) + } +}) + +test('detectAdoption: a .git file (worktree checkout) is a repo root too', () => { + const repo = tempDir('dsh-harness-worktree-') + try { + write(join(repo, '.git'), `gitdir: ${join(repo, '..', 'main', '.git', 'worktrees', 'x')}\n`) + write(join(repo, 'openspec', 'specs', '.keep'), '') + write(join(repo, '.codegraph', '.keep'), '') + write(join(repo, 'CLAUDE.md'), SECTION) + + assert.deepEqual(detectAdoption(join(repo, 'openspec')), { + adopted: true, + root: repo, + missing: [], + }) + } finally { + cleanup(repo) + } +}) + +// --------------------------------------------------------------------------- +// buildHarnessCheckText +// --------------------------------------------------------------------------- + +test('buildHarnessCheckText names the root, missing signals, and next steps', () => { + const text = buildHarnessCheckText('/tmp/proj', [ + 'openspec/', + 'секция «OpenSpec и superpowers» в CLAUDE.md', + ]) + assert.match( + text, + /^\[dsh-harness\] Проект \/tmp\/proj не подключён к обвязке: нет: openspec\/; секция «OpenSpec и superpowers» в CLAUDE\.md\. /, + ) + assert.match(text, /openspec init --tools agents/) + assert.match(text, /codegraph init/) + assert.match(text, /Предложи владельцу настройку/) +}) + +// --------------------------------------------------------------------------- +// detectRole +// --------------------------------------------------------------------------- + +test('detectRole: admin keywords (jira, гант, спринт)', () => { + assert.equal(detectRole('Разбери jira-задачу'), 'admin') + assert.equal(detectRole('Покажи гант проекта'), 'admin') + assert.equal(detectRole('Новый спринт начинается'), 'admin') + assert.equal(detectRole('JIRA-тикет'), 'admin') +}) + +test('detectRole: qa keywords (стенд, прогон, скриншот, макет)', () => { + assert.equal(detectRole('Подними стенд'), 'qa') + assert.equal(detectRole('Нужен прогон сценария'), 'qa') + assert.equal(detectRole('Сделай скриншот'), 'qa') + assert.equal(detectRole('Сверь с макетом'), 'qa') +}) + +test('detectRole: analyst keywords (тз, oq, спека, методика)', () => { + assert.equal(detectRole('Оформи ТЗ'), 'analyst') + assert.equal(detectRole('Закрой OQ'), 'analyst') + assert.equal(detectRole('Спека готова'), 'analyst') + assert.equal(detectRole('Нужна методика'), 'analyst') +}) + +test('detectRole: dev keywords (срез, issue, pr, тест, миграция)', () => { + assert.equal(detectRole('Сними срез метрик'), 'dev') + assert.equal(detectRole('Открой issue'), 'dev') + assert.equal(detectRole('Проверь тест'), 'dev') + assert.equal(detectRole('Нужна миграция'), 'dev') +}) + +test('detectRole: pr matches only as a whole word', () => { + assert.equal(detectRole('сделай PR'), 'dev') + assert.equal(detectRole('просто посмотри'), null) + assert.equal(detectRole('preview the branch'), null) +}) + +test('detectRole: priority is admin before qa, analyst, and dev', () => { + assert.equal(detectRole('спринт и тест'), 'admin') + assert.equal(detectRole('прогон и issue'), 'qa') + assert.equal(detectRole('спека и тест'), 'analyst') +}) + +test('detectRole: no keyword or empty input yields null', () => { + assert.equal(detectRole('Привет, как дела?'), null) + assert.equal(detectRole(''), null) +}) + +// --------------------------------------------------------------------------- +// buildRoleHintText +// --------------------------------------------------------------------------- + +test('buildRoleHintText: known role names it and asks to load the skill', () => { + const text = buildRoleHintText('qa') + assert.ok(text.startsWith('[dsh-harness]')) + assert.match(text, /Роль сессии: qa\./) + assert.match(text, /Вызови skill role\./) +}) + +test('buildRoleHintText: null asks one question with all four roles', () => { + const text = buildRoleHintText(null) + assert.ok(text.startsWith('[dsh-harness]')) + assert.match(text, /Роль сессии неясна\./) + for (const role of ['dev', 'admin', 'analyst', 'qa']) { + assert.ok(text.includes(`${role} —`), `${role} variant is listed`) + } + assert.match(text, /четырьмя вариантами/) +}) + +// --------------------------------------------------------------------------- +// isHarnessMessage +// --------------------------------------------------------------------------- + +test('isHarnessMessage: bootstrap, DSH, and legacy claude-harness texts are harness messages', () => { + assert.equal(isHarnessMessage('\nYou have superpowers.'), true) + assert.equal(isHarnessMessage('[dsh-harness] Роль сессии: dev.'), true) + assert.equal(isHarnessMessage('[claude-harness] Роль сессии: dev'), true) +}) + +test('isHarnessMessage: plain user text is not a harness message', () => { + assert.equal(isHarnessMessage('Привет! Разбери задачу.'), false) + assert.equal(isHarnessMessage(''), false) +}) + +// --------------------------------------------------------------------------- +// makePrestepMarker +// --------------------------------------------------------------------------- + +test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', () => { + const { marker, body } = makePrestepMarker('Привет') + assert.match(marker, /^$/) + assert.ok(body.startsWith(marker)) + assert.equal(body, `${marker}\nПривет`) + + assert.equal(makePrestepMarker('Привет').marker, marker, 'same text, same marker') + assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker') + assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters') +}) -- 2.47.2 From 37daa97915106e4c947d5d833a9bfffc460f6109 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 11:36:38 +0400 Subject: [PATCH 09/17] =?UTF-8?q?docs:=20approval.md=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5-=D0=B0=D0=BF=D0=BF=D1=80=D1=83=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/approval.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/dsh-prestep/approval.md b/openspec/changes/dsh-prestep/approval.md index a13d55f..c9075df 100644 --- a/openspec/changes/dsh-prestep/approval.md +++ b/openspec/changes/dsh-prestep/approval.md @@ -4,5 +4,5 @@ design: plan: by: owner - at: 2026-10-25T07:29:41.328Z - sha256: 94a10b74e44baec7d257de3dff992c1493fc46c3192511cc22867362c88cbd1d + at: 2026-10-25T07:34:55.231Z + sha256: dd03b3ad25b29024bbca9b0957a07020b316bd52a1aaa9e81df69ff17fd975e2 -- 2.47.2 From a2453e35e83907044d1519861a630679ce41c7fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 11:47:28 +0400 Subject: [PATCH 10/17] =?UTF-8?q?feat(dsh):=20wiring=20harness-check/role-?= =?UTF-8?q?hint=20=D0=B2=20pre-step,=20isInjectionVisible=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/bundle/index.js | 114 +++++++++++++++++++----- dsh/bundle/lib/bootstrap.js | 7 +- dsh/tests/bootstrap.test.mjs | 15 ++-- dsh/tests/package.test.mjs | 1 + dsh/tests/prestep.test.mjs | 165 +++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 29 deletions(-) diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index 761578e..a0bb9a3 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -5,7 +5,8 @@ * - PreToolUse Edit/Write/Bash -> `ctx.tools.guard` calling the existing * `hooks/guard-main.sh` and `hooks/require-plan.sh` through a thin adapter; * - SessionStart superpowers -> `agent/pre-step` injecting - * `using-superpowers` once per session (and again after compaction); + * `using-superpowers` once per session (and again after compaction), + * plus the harness-check and role-hint messages; * - `/role` skill -> a real `/role` command registering on * `ctx.commands`. * @@ -33,9 +34,17 @@ import { checkPlanWrite, checkSddWrite, } from './lib/gates.js' -import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js' +import { isInjectionVisible, prepareBootstrap, textOf } from './lib/bootstrap.js' import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js' import { createUserMessage } from './lib/message.js' +import { + buildHarnessCheckText, + buildRoleHintText, + detectAdoption, + detectRole, + isHarnessMessage, + makePrestepMarker, +} from './lib/prestep.js' import { DEFAULT_ROLE_NAMES } from './lib/role.js' import { collectSelfCheck } from './lib/selfcheck.js' @@ -186,6 +195,8 @@ const DEFAULTS = { approvalGates: true, bootstrap: true, bootstrapSubagents: false, + harnessCheck: true, + roleHint: true, roleCommand: true, selfCheck: true, checkCommand: true, @@ -256,31 +267,90 @@ export function apply(ctx, rawConfig = {}) { }) } - if (config.enabled && config.bootstrap && bootstrapOk) { + // The pre-step listener is registered when any of the three injections is + // wanted: the bootstrap needs its service from the self-check, while + // harness-check and role-hint gate directly on ctx.on (D-5) so they stay + // alive with `bootstrap:false`. + const prestepsWanted = + (config.bootstrap && bootstrapOk) || config.harnessCheck || config.roleHint + if (config.enabled && prestepsWanted && typeof ctx.on === 'function') { ctx.on('agent/pre-step', async ({ agent, signal }, next) => { const decision = await next() if (decision.kind === 'reject' || signal?.aborted) return decision - if (!config.bootstrapSubagents && isSubagent(agent)) return decision + const onOk = typeof ctx.on === 'function' + let result = decision - const prepared = prepareBootstrap({ - skillPath: config.bootstrapSkillPath, - mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'), - maxBytes: config.bootstrapMaxBytes, - logger: ctx.logger, - }) - if (!prepared) return decision - if (isBootstrapVisible(agent, decision.messages, prepared.marker)) return decision - - return { - ...decision, - messages: [ - ...(decision.messages ?? []), - createUserMessage({ - content: [{ type: 'text', text: prepared.text }], - source: { kind: 'user' }, - }), - ], + if (config.bootstrap && bootstrapOk) { + if (config.bootstrapSubagents || !isSubagent(agent)) { + const prepared = prepareBootstrap({ + skillPath: config.bootstrapSkillPath, + mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'), + maxBytes: config.bootstrapMaxBytes, + logger: ctx.logger, + }) + if (prepared && !isInjectionVisible(agent, result.messages, prepared.marker)) { + result = { + ...result, + messages: [ + ...(result.messages ?? []), + createUserMessage({ + content: [{ type: 'text', text: prepared.text }], + source: { kind: 'user' }, + }), + ], + } + } + } } + + if (config.enabled && config.harnessCheck && onOk && !isSubagent(agent)) { + const cwd = sessionCwd(ctx.workingDirectory, agent) + const res = detectAdoption(cwd) + if (!res.adopted) { + const { marker, body } = makePrestepMarker( + buildHarnessCheckText(res.root ?? cwd, res.missing), + ) + if (!isInjectionVisible(agent, result.messages, marker)) { + result = { + ...result, + messages: [ + ...(result.messages ?? []), + createUserMessage({ + content: [{ type: 'text', text: body }], + source: { kind: 'user' }, + }), + ], + } + } + } + } + + if (config.enabled && config.roleHint && onOk && !isSubagent(agent)) { + const firstUser = (result.messages ?? []).find((message) => { + if (message?.source?.kind !== 'user') return false + const text = textOf(message) + return text !== '' && !isHarnessMessage(text) + }) + if (firstUser) { + const { marker, body } = makePrestepMarker( + buildRoleHintText(detectRole(textOf(firstUser))), + ) + if (!isInjectionVisible(agent, result.messages, marker)) { + result = { + ...result, + messages: [ + ...(result.messages ?? []), + createUserMessage({ + content: [{ type: 'text', text: body }], + source: { kind: 'user' }, + }), + ], + } + } + } + } + + return result }) } diff --git a/dsh/bundle/lib/bootstrap.js b/dsh/bundle/lib/bootstrap.js index 99b49f4..7964af0 100644 --- a/dsh/bundle/lib/bootstrap.js +++ b/dsh/bundle/lib/bootstrap.js @@ -195,11 +195,11 @@ export function textOf(value) { } /** - * Whether this exact bootstrap marker is still visible in the session. + * Whether this exact injection marker is still visible in the session. * Messages under consideration by the current pre-step are checked first; * then the durable visible surface, which drops a message after compaction. */ -export function isBootstrapVisible(agent, messages, marker) { +export function isInjectionVisible(agent, messages, marker) { if (!marker) return false if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true @@ -218,3 +218,6 @@ export function isBootstrapVisible(agent, messages, marker) { } return false } + +/** Back-compat name for the pre-refactor bootstrap visibility check. */ +export const isBootstrapVisible = isInjectionVisible diff --git a/dsh/tests/bootstrap.test.mjs b/dsh/tests/bootstrap.test.mjs index 1af85ce..da4cd8b 100644 --- a/dsh/tests/bootstrap.test.mjs +++ b/dsh/tests/bootstrap.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { buildBootstrapText, isBootstrapVisible, + isInjectionVisible, prepareBootstrap, resolveBootstrapSkill, stripFrontmatter, @@ -53,10 +54,10 @@ test('buildBootstrapText is deterministic for the same content', () => { assert.equal(buildBootstrapText(args), buildBootstrapText(args)) }) -test('isBootstrapVisible sees the marker in messages and in the visible surface', () => { +test('isInjectionVisible sees the marker in messages and in the visible surface', () => { const marker = '' - assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true) - assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false) + assert.equal(isInjectionVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true) + assert.equal(isInjectionVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false) const event = { type: 'user/message', data: { content: [{ type: 'text', text: `prefix ${marker}` }] } } const visibleAgent = { @@ -66,12 +67,16 @@ test('isBootstrapVisible sees the marker in messages and in the visible surface' eventAt: (seq) => (seq === 1 ? event : undefined), }, } - assert.equal(isBootstrapVisible(visibleAgent, [], marker), true) + assert.equal(isInjectionVisible(visibleAgent, [], marker), true) const compactedAgent = { session: { seq: 3, surface: { nodes: new Set() }, eventAt: () => undefined }, } - assert.equal(isBootstrapVisible(compactedAgent, [], marker), false) + assert.equal(isInjectionVisible(compactedAgent, [], marker), false) +}) + +test('isBootstrapVisible is an alias of isInjectionVisible', () => { + assert.equal(isBootstrapVisible, isInjectionVisible) }) test('resolveBootstrapSkill prefers an explicit path and finds the user root', () => { diff --git a/dsh/tests/package.test.mjs b/dsh/tests/package.test.mjs index 010625d..3f25a34 100644 --- a/dsh/tests/package.test.mjs +++ b/dsh/tests/package.test.mjs @@ -62,6 +62,7 @@ test('runtime files have no external @deepseek-ai imports', () => { 'lib/guards.js', 'lib/gates.js', 'lib/bootstrap.js', + 'lib/prestep.js', 'lib/role.js', 'lib/legacy.js', 'lib/selfcheck.js', diff --git a/dsh/tests/prestep.test.mjs b/dsh/tests/prestep.test.mjs index 5b013aa..972275c 100644 --- a/dsh/tests/prestep.test.mjs +++ b/dsh/tests/prestep.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { join } from 'node:path' import test from 'node:test' +import { apply } from '../bundle/index.js' import { buildHarnessCheckText, buildRoleHintText, @@ -212,3 +213,167 @@ test('makePrestepMarker: marker format, body prefix, determinism, uniqueness', ( assert.notEqual(makePrestepMarker('Пока').marker, marker, 'different text, different marker') assert.notEqual(makePrestepMarker('привет').marker, marker, 'case matters') }) + +// --------------------------------------------------------------------------- +// apply(): wiring of harness-check and role-hint into agent/pre-step +// --------------------------------------------------------------------------- + +/** Text of the first text block of a message. */ +function messageText(message) { + return message.content[0].text +} + +/** + * apply() against a fake ctx: the `agent/pre-step` listener is captured and + * can be run with a canned decision. The session cwd is served by the fake + * workingDirectory; bootstrap is off in most cases so its big text does not + * pollute the message lists. + */ +function prestepHarness(config = {}, cwd) { + const listeners = {} + const ctx = { + tools: { guard() {} }, + on(event, handler) { listeners[event] = handler }, + inject() {}, + workingDirectory: { get() { return cwd } }, + logger: { info() {}, warn() {}, error() {} }, + } + apply(ctx, config) + const handler = listeners['agent/pre-step'] + assert.ok(handler, 'agent/pre-step listener is registered') + return { + run({ agent = {}, messages = [] } = {}) { + return handler({ agent, signal: {} }, async () => ({ kind: 'continue', messages })) + }, + } +} + +/** A tree with `.git` but none of the adoption signals. */ +function nonAdoptedRepo() { + const repo = tempDir('dsh-harness-noadopt-') + write(join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n') + write(join(repo, 'CLAUDE.md'), '# Проект без секции\n') + return repo +} + +function userMessage(text) { + return { content: [{ type: 'text', text }], source: { kind: 'user' } } +} + +test('apply: harness-check injects when adoption signals are missing', async () => { + const repo = nonAdoptedRepo() + try { + const harness = prestepHarness({ bootstrap: false }, repo) + const decision = await harness.run({ agent: { session: 's1' }, messages: [] }) + + assert.equal(decision.messages.length, 1) + const text = messageText(decision.messages[0]) + assert.match(text, /^/) + assert.match(text, /\[dsh-harness\] Проект .* не подключён к обвязке/) + assert.match(text, /openspec init --tools agents/) + assert.equal(decision.messages[0].source.kind, 'user') + } finally { + cleanup(repo) + } +}) + +test('apply: harness-check stays silent for an adopted repo and outside git', async () => { + const repo = adoptedRepo() + try { + const harness = prestepHarness({ bootstrap: false }, repo) + const decision = await harness.run({ agent: { session: 's1' }, messages: [] }) + assert.deepEqual(decision.messages, []) + } finally { + cleanup(repo) + } + + const outside = tempDir('dsh-harness-outside-') + try { + const harness = prestepHarness({ bootstrap: false }, outside) + const decision = await harness.run({ agent: { session: 's1' }, messages: [] }) + assert.deepEqual(decision.messages, []) + } finally { + cleanup(outside) + } +}) + +test('apply: role-hint injects Роль сессии: dev for a keyword user message', async () => { + const outside = tempDir('dsh-harness-outside-') + try { + const harness = prestepHarness({ bootstrap: false }, outside) + const user = userMessage('Напиши тест для модуля') + const decision = await harness.run({ agent: { session: 's1' }, messages: [user] }) + + assert.equal(decision.messages.length, 2) + const text = messageText(decision.messages[1]) + assert.match(text, /^/) + assert.match(text, /\[dsh-harness\] Роль сессии: dev\./) + } finally { + cleanup(outside) + } +}) + +test('apply: role-hint asks the four-variant question when no keyword matches', async () => { + const outside = tempDir('dsh-harness-outside-') + try { + const harness = prestepHarness({ bootstrap: false }, outside) + const user = userMessage('Привет, как дела?') + const decision = await harness.run({ agent: { session: 's1' }, messages: [user] }) + + assert.equal(decision.messages.length, 2) + const text = messageText(decision.messages[1]) + assert.match(text, /^/) + assert.match(text, /\[dsh-harness\] Роль сессии неясна\./) + assert.match(text, /четырьмя вариантами/) + } finally { + cleanup(outside) + } +}) + +test('apply: role-hint stays silent when the step has no new user message', async () => { + const outside = tempDir('dsh-harness-outside-') + try { + const harness = prestepHarness({ bootstrap: false }, outside) + const decision = await harness.run({ agent: { session: 's1' }, messages: [] }) + assert.deepEqual(decision.messages, []) + } finally { + cleanup(outside) + } +}) + +test('apply: a user message that equals the role-hint question does not re-trigger role-hint', async () => { + const outside = tempDir('dsh-harness-outside-') + try { + const harness = prestepHarness({ bootstrap: false }, outside) + const user = userMessage(buildRoleHintText(null)) + const decision = await harness.run({ agent: { session: 's1' }, messages: [user] }) + assert.deepEqual(decision.messages, [user], 'isHarnessMessage filters before detectRole') + } finally { + cleanup(outside) + } +}) + +test('apply: a [claude-harness]-prefixed user message is skipped by role-hint', async () => { + const outside = tempDir('dsh-harness-outside-') + try { + const harness = prestepHarness({ bootstrap: false }, outside) + const user = userMessage('[claude-harness] Роль сессии: dev. Вызови skill role.') + const decision = await harness.run({ agent: { session: 's1' }, messages: [user] }) + assert.deepEqual(decision.messages, [user], 'legacy prefix is filtered too') + } finally { + cleanup(outside) + } +}) + +test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not bootstrapOk)', async () => { + const repo = nonAdoptedRepo() + try { + const harness = prestepHarness({ bootstrap: false, roleHint: false }, repo) + const decision = await harness.run({ agent: { session: 's1' }, messages: [] }) + + assert.equal(decision.messages.length, 1) + assert.match(messageText(decision.messages[0]), /не подключён к обвязке/) + } finally { + cleanup(repo) + } +}) -- 2.47.2 From 1d35a3be2cf8ed1c5dc3c4fc672fb4342f0b3a5a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 12:03:58 +0400 Subject: [PATCH 11/17] =?UTF-8?q?fix(dsh):=20isSubagent=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=20=D1=81=D0=B8?= =?UTF-8?q?=D0=B3=D0=BD=D0=B0=D0=BB=D0=B0=D0=BC=20=D1=80=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BC=D0=B0=20(T2.1,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/bundle/index.js | 9 ++++--- dsh/tests/prestep.test.mjs | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index a0bb9a3..eefaa10 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -73,10 +73,11 @@ export function resolveRolesDir(bundleDir, configured, home = homedir()) { function isSubagent(agent) { - if (!agent) return false - if (agent.parent || agent.parentAgent || agent.parentSessionId) return true - if (typeof agent.depth === 'number' && agent.depth > 0) return true - return false + return Boolean( + agent?.session?.header?.delegationDepth > 0 || + agent?.session?.header?.parentSession != null || + agent?.options?.subagentDepth > 0, + ) } function sessionCwd(workingDirectory, agent) { diff --git a/dsh/tests/prestep.test.mjs b/dsh/tests/prestep.test.mjs index 972275c..0ddff3f 100644 --- a/dsh/tests/prestep.test.mjs +++ b/dsh/tests/prestep.test.mjs @@ -377,3 +377,56 @@ test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not cleanup(repo) } }) + +// --------------------------------------------------------------------------- +// Subagent skip (T2.1): real runtime delegation signals +// --------------------------------------------------------------------------- + +/** + * Agent shapes that the runtime uses for a delegated step. The old + * parent/parentAgent/parentSessionId/depth checks were dead; these three are + * the live ones. + */ +const SUBAGENT_AGENTS = [ + { session: { header: { delegationDepth: 1 } } }, + { session: { header: { parentSession: 's0' } } }, + { options: { subagentDepth: 1 } }, +] + +test('apply: a delegated pre-step injects nothing (bootstrap, harness-check, role-hint)', async () => { + const repo = nonAdoptedRepo() + const skill = tempDir('dsh-harness-skill-') + try { + write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') + const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo) + + for (const agent of SUBAGENT_AGENTS) { + const decision = await harness.run({ agent, messages: [userMessage('Напиши тест')] }) + assert.equal(decision.messages.length, 1, `no injection for ${JSON.stringify(agent)}`) + assert.equal(messageText(decision.messages[0]), 'Напиши тест') + } + } finally { + cleanup(repo) + cleanup(skill) + } +}) + +test('apply: a plain pre-step still injects bootstrap, harness-check, and role-hint', async () => { + const repo = nonAdoptedRepo() + const skill = tempDir('dsh-harness-skill-') + try { + write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') + const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo) + const decision = await harness.run({ agent: { session: 's1' }, messages: [userMessage('Напиши тест')] }) + + assert.equal(decision.messages.length, 4) + assert.equal(messageText(decision.messages[0]), 'Напиши тест') + const texts = decision.messages.map(messageText) + assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected') + assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected') + assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected') + } finally { + cleanup(repo) + cleanup(skill) + } +}) -- 2.47.2 From 622dd32e1acf9b01b519a227b120bb5e2dde61b8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 12:11:05 +0400 Subject: [PATCH 12/17] =?UTF-8?q?fix(dsh):=20isSubagent=20=E2=80=94=20orig?= =?UTF-8?q?in=3D=3D=3Dsubagent=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20pa?= =?UTF-8?q?rentSession=20(=D1=84=D0=BE=D1=80=D0=BA=D0=B8=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D0=B0=D0=B1=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B)=20(T2.3?= =?UTF-8?q?,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/bundle/index.js | 2 +- dsh/tests/prestep.test.mjs | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index eefaa10..a29085a 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -75,7 +75,7 @@ export function resolveRolesDir(bundleDir, configured, home = homedir()) { function isSubagent(agent) { return Boolean( agent?.session?.header?.delegationDepth > 0 || - agent?.session?.header?.parentSession != null || + agent?.session?.header?.origin === 'subagent' || agent?.options?.subagentDepth > 0, ) } diff --git a/dsh/tests/prestep.test.mjs b/dsh/tests/prestep.test.mjs index 0ddff3f..81ac083 100644 --- a/dsh/tests/prestep.test.mjs +++ b/dsh/tests/prestep.test.mjs @@ -383,13 +383,13 @@ test('apply: bootstrap:false leaves harness-check wired (the gate is ctx.on, not // --------------------------------------------------------------------------- /** - * Agent shapes that the runtime uses for a delegated step. The old - * parent/parentAgent/parentSessionId/depth checks were dead; these three are - * the live ones. + * Agent shapes that the runtime uses for a delegated step: the session header + * carries `origin: 'subagent'` (or a positive delegationDepth / subagentDepth + * as a robust fallback). */ const SUBAGENT_AGENTS = [ { session: { header: { delegationDepth: 1 } } }, - { session: { header: { parentSession: 's0' } } }, + { session: { header: { origin: 'subagent', parentSession: 's0' } } }, { options: { subagentDepth: 1 } }, ] @@ -430,3 +430,23 @@ test('apply: a plain pre-step still injects bootstrap, harness-check, and role-h cleanup(skill) } }) + +test('apply: a forked session (parentSession without origin) still injects all three', async () => { + const repo = nonAdoptedRepo() + const skill = tempDir('dsh-harness-skill-') + try { + write(join(skill, 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n') + const harness = prestepHarness({ bootstrapSkillPath: join(skill, 'SKILL.md') }, repo) + const forkAgent = { session: { header: { parentSession: 's0', isSeeded: true } } } + const decision = await harness.run({ agent: forkAgent, messages: [userMessage('Напиши тест')] }) + + assert.equal(decision.messages.length, 4, 'forks are not subagents: all three injections expected') + const texts = decision.messages.map(messageText) + assert.ok(texts.some((text) => text.includes('DSH-HARNESS-BOOTSTRAP')), 'bootstrap injected') + assert.ok(texts.some((text) => text.includes('не подключён к обвязке')), 'harness-check injected') + assert.ok(texts.some((text) => text.includes('Роль сессии: dev')), 'role-hint injected') + } finally { + cleanup(repo) + cleanup(skill) + } +}) -- 2.47.2 From 183f8d89568688583fa9fc737188bbbbf7dfc4c0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 12:19:53 +0400 Subject: [PATCH 13/17] =?UTF-8?q?fix(dsh):=20#62=20=E2=80=94=20PATH-=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=BB=D1=8F=D1=86=D0=B8=D1=8F=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0,=20WARN=20DSH=5FHOME,=20TDD-=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BE=D0=BA=20=D0=B2=20sdd=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/install.sh | 23 ++++++++++++++++++ dsh/skills/sdd/SKILL.md | 10 ++++---- dsh/tests/install.test.mjs | 39 ++++++++++++++++++++++++++++++ dsh/tests/openspec.test.mjs | 47 +++++++++++++++++++++++++++---------- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/dsh/install.sh b/dsh/install.sh index 5ce3d81..8e7af16 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -21,6 +21,14 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" BUNDLE="$ROOT/dsh/bundle" +# O-2: remember whether DSH_HOME came from the environment. Isolating HOME +# alone is not enough: an explicit DSH_HOME still writes outside the isolated +# tree (a temp HOME with the real DSH_HOME, typically a test or a detached +# shell), so the install steps warn about that mixed state. +DSH_HOME_FROM_ENV=0 +if [ -n "${DSH_HOME:-}" ]; then + DSH_HOME_FROM_ENV=1 +fi DSH_HOME="${DSH_HOME:-$HOME/.dsh}" DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}" DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}" @@ -498,7 +506,21 @@ copy_skills_from() { echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)" } +# O-2: an explicit DSH_HOME that already exists while $HOME is another tree is +# the mixed case: the install writes into the existing DSH_HOME instead of the +# isolated one. Warn once per run and continue — the target is valid, only the +# HOME/DSH_HOME pair is not isolated together. +warn_mixed_dsh_home() { + if [ "$DSH_HOME_FROM_ENV" != 1 ]; then return 0; fi + if [ "$DSH_HOME" = "$HOME/.dsh" ]; then return 0; fi + if [ ! -d "$DSH_HOME" ]; then return 0; fi + if [ "${DSH_HOME_WARNED:-0}" = 1 ]; then return 0; fi + DSH_HOME_WARNED=1 + echo "WARN: DSH_HOME=$DSH_HOME points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together" >&2 +} + install_skills() { + warn_mixed_dsh_home mkdir -p "$DSH_HOME/skills" # Explicit source: sync it directly, no network, no pin enforcement. @@ -576,6 +598,7 @@ install_local_skills() { } install_roles() { + warn_mixed_dsh_home mkdir -p "$DSH_HOME/roles" local src="$ROOT/roles" local count=0 diff --git a/dsh/skills/sdd/SKILL.md b/dsh/skills/sdd/SKILL.md index e9fefab..c86da11 100644 --- a/dsh/skills/sdd/SKILL.md +++ b/dsh/skills/sdd/SKILL.md @@ -9,7 +9,7 @@ description: Use when executing an implementation plan in DeepSeek Harness (DSH) только в DSH-сессиях: у Claude Code синхронный `Task`, у DSH — асинхронный `subagent`. Это **не паритет** с Claude-SDD и не имитация: протокол построен на том, что DSH умеет (уведомление о завершении, `send_message`, ledger), а не -на том, чего у него нет (возврат результата из вызова инструмента). +на том, чего у него нет. ## Когда применять @@ -62,6 +62,9 @@ overlay ребёнок наследует модель сессии. 6. Замеченное расхождение с брифом или спекой — это `DONE_WITH_CONCERNS`, даже если задача формально выполнена: ревью его проверит, контроллер — решит. +**Порядок TDD.** Шаг «напиши тест и убедись, что он падает (RED)» обязателен +ДО кода; реализация без RED — `DONE_WITH_CONCERNS`, ревью это проверяет. + ## Ожидание без блокировки `subagent` возвращает id и сразу завершается; результата в значении @@ -135,9 +138,8 @@ ledger лежит в git-ignored scratch (после `git clean -fdx` восст фиксирует это наблюдением `O-n`. `model` нельзя передать один, «унаследовав» провайдера: DSH требует `provider` и `model` вместе и на односторонний маршрут отвечает «child LLM provider and model must be supplied together». -Эта ошибка означает, что в вызове не хватает `provider` (в прогоне #41 так -случилось дважды), а не сбой валидации: проверь аргументы и повтори вызов, -передав оба поля. +Эта ошибка означает, что в вызове не хватает `provider`, а не сбой валидации: +проверь аргументы и повтори вызов, передав оба поля. ## Отличие от Claude-SDD diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index 373817f..ad4e4e8 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -54,6 +54,9 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', () assert.match(sddText, /name: sdd/) assert.match(sddText, /\/approve/, 'the sdd skill must check the plan approval marker before dispatch') assert.match(sddText, /approval\.md/, 'the sdd skill must name the approval marker file') + // T3.4: TDD order is part of the dispatch protocol: RED before the code. + assert.match(sddText, /падает \(RED\)/, 'the sdd skill must require a failing test (RED) before the code') + assert.match(sddText, /реализация без RED — `DONE_WITH_CONCERNS`/, 'a missing RED must degrade the report status') for (const role of ['dev', 'admin', 'analyst', 'qa']) { assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`) @@ -638,6 +641,42 @@ test('--global installs DSH role paths and explains the instruction format', () } }) +test('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2)', () => { + const work = tempDir() + try { + const home = join(work, 'home') + // An existing DSH_HOME outside the isolated HOME is the mixed state: the + // install would write outside the sandbox. The warning must not abort. + const sharedDsh = join(work, 'shared-dsh') + mkdirSync(home, { recursive: true }) + mkdirSync(sharedDsh, { recursive: true }) + const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers')) + const warn = `WARN: DSH_HOME=${sharedDsh} points to an existing directory with a non-standard HOME; both HOME and DSH_HOME must be isolated together` + + const skills = runInstaller(home, ['--skills', '--superpowers-from', src], { DSH_HOME: sharedDsh }) + const skillsOut = skills.stdout + skills.stderr + assert.equal(skills.status, 0, skillsOut) + assert.ok(skillsOut.includes(warn), skillsOut) + assert.ok( + existsSync(join(sharedDsh, 'skills', 'using-superpowers', 'SKILL.md')), + 'the install still runs after the warning', + ) + + const roles = runInstaller(home, ['--roles'], { DSH_HOME: sharedDsh }) + const rolesOut = roles.stdout + roles.stderr + assert.equal(roles.status, 0, rolesOut) + assert.ok(rolesOut.includes(warn), rolesOut) + + // Isolated together ($DSH_HOME=$HOME/.dsh): no warning. + const normal = runInstaller(home, ['--skills', '--superpowers-from', src]) + const normalOut = normal.stdout + normal.stderr + assert.equal(normal.status, 0, normalOut) + assert.doesNotMatch(normalOut, /WARN: DSH_HOME=/) + } finally { + cleanup(work) + } +}) + test('a legacy cache without using-superpowers is not a source', () => { const home = tempDir() try { diff --git a/dsh/tests/openspec.test.mjs b/dsh/tests/openspec.test.mjs index ad71c8b..eb940cd 100644 --- a/dsh/tests/openspec.test.mjs +++ b/dsh/tests/openspec.test.mjs @@ -19,6 +19,32 @@ const NAMES = [ 'openspec-update-change', ] +// #62 T2.1/M-2: a negative test must not inherit the host PATH — on Linux a +// global npm prefix puts openspec into /usr/bin, so a fixed system PATH is not +// openspec-free. The child gets a guard dir holding symlinks to the binaries +// the script needs and nothing else. `bash` is addressed by absolute path +// because libuv resolves the command with the child's restricted PATH. +function which(name) { + const out = spawnSync('bash', ['-c', `command -v ${name}`], { encoding: 'utf8' }) + return out.stdout.trim() +} + +const BASH = which('bash') || '/bin/bash' + +function isolatedPath(home) { + const bin = join(home, 'guard-bin') + mkdirSync(bin) + // The toolchain --check verifies (python3, git, node) plus the POSIX tools + // install.sh calls on this path (dirname for ROOT, head for versions, the + // ls/sort/tail legacy lookup). openspec is deliberately absent. + for (const name of ['python3', 'git', 'node', 'dirname', 'head', 'ls', 'sort', 'tail']) { + const target = which(name) + assert.ok(target, `cannot resolve ${name}: the isolated PATH would be incomplete`) + symlinkSync(target, join(bin, name)) + } + return bin +} + test('repository ships six agents skills for DSH', () => { assert.equal(readFileSync(join(agentsSkills, '.openspec-target'), 'utf8').trim(), 'agents') for (const name of NAMES) { @@ -49,17 +75,16 @@ test('install.sh --check reports the openspec CLI and the agents skills', () => test('install.sh --check warns but does not fail without the openspec CLI', () => { const home = tempDir() try { - const bin = join(home, 'bin') - mkdirSync(bin, { recursive: true }) - symlinkSync(process.execPath, join(bin, 'node')) - // openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден + // #62 T2.1: the guard dir is the whole PATH, so a host openspec CLI (e.g. + // /usr/bin on Linux) can never leak into this negative test. + const bin = isolatedPath(home) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), - PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, + PATH: bin, } - const run = spawnSync('bash', [script, '--check'], { env, encoding: 'utf8' }) + const run = spawnSync(BASH, [script, '--check'], { env, encoding: 'utf8' }) assert.equal(run.status, 0, run.stderr) assert.match(run.stdout + run.stderr, /WARN: openspec not found/) } finally { @@ -134,17 +159,15 @@ test('--openspec fails without the openspec CLI', () => { try { const project = join(home, 'project') mkdirSync(join(project, 'openspec'), { recursive: true }) - const bin = join(home, 'bin') - mkdirSync(bin, { recursive: true }) - symlinkSync(process.execPath, join(bin, 'node')) - // openspec ставится в /opt/homebrew/bin; здесь только системные каталоги — CLI не найден + // #62 T2.1: same guard-dir isolation as the --check negative test. + const bin = isolatedPath(home) const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh'), - PATH: `${bin}:/usr/bin:/bin:/usr/sbin:/sbin`, + PATH: bin, } - const run = spawnSync('bash', [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) + const run = spawnSync(BASH, [script, '--openspec'], { cwd: project, env, encoding: 'utf8' }) assert.notEqual(run.status, 0) assert.match(run.stdout + run.stderr, /FAIL: openspec CLI not found/) } finally { -- 2.47.2 From 6800550cd540d8ed6841f0c7d30266a4fc0e3509 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 12:29:30 +0400 Subject: [PATCH 14/17] =?UTF-8?q?fix(dsh):=20O-2=20=E2=80=94=20WARN=20?= =?UTF-8?q?=D0=B8=20=D0=B2=20install=5Fglobal,=20=D0=B0=D1=81=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D1=8B=20=D0=B0=D1=80=D1=82=D0=B5=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/install.sh | 1 + dsh/tests/install.test.mjs | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/dsh/install.sh b/dsh/install.sh index 8e7af16..f9f9cf8 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -372,6 +372,7 @@ check() { } install_global() { + warn_mixed_dsh_home mkdir -p "$DSH_HOME" local target="$DSH_HOME/AGENTS.md" if [ -f "$target" ]; then diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index ad4e4e8..d43c708 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -666,6 +666,21 @@ test('--skills and --roles warn when DSH_HOME exists while HOME is isolated (O-2 const rolesOut = roles.stdout + roles.stderr assert.equal(roles.status, 0, rolesOut) assert.ok(rolesOut.includes(warn), rolesOut) + assert.ok(existsSync(join(sharedDsh, 'roles', 'dev.md')), 'roles land in the warned DSH_HOME') + + // --global overwrites AGENTS.md (after a backup): the destructive step must + // warn too (T3.1). + const global = runInstaller(home, ['--global'], { DSH_HOME: sharedDsh }) + const globalOut = global.stdout + global.stderr + assert.equal(global.status, 0, globalOut) + assert.ok(globalOut.includes(warn), globalOut) + assert.ok(existsSync(join(sharedDsh, 'AGENTS.md')), 'AGENTS.md is installed after the warning') + + // One run touching several steps warns exactly once (T3.2). + const combined = runInstaller(home, ['--skills', '--roles', '--superpowers-from', src], { DSH_HOME: sharedDsh }) + const combinedOut = combined.stdout + combined.stderr + assert.equal(combined.status, 0, combinedOut) + assert.equal((combinedOut.match(/WARN: DSH_HOME=/g) ?? []).length, 1, combinedOut) // Isolated together ($DSH_HOME=$HOME/.dsh): no warning. const normal = runInstaller(home, ['--skills', '--superpowers-from', src]) -- 2.47.2 From 727a9dd9bc918e3c1b62f11e191c3df9befc9763 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 12:42:26 +0400 Subject: [PATCH 15/17] =?UTF-8?q?docs(openspec):=20=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=20session-prestep,=20=D0=B4=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=D1=8B=20T1.2/T2.2/T1.3/?= =?UTF-8?q?T1.5=20(#43,=20#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/PORT.md | 67 ++++- dsh/README.md | 42 ++- dsh/bundle/dsh-tools.md | 6 + openspec/changes/dsh-prestep/design.md | 27 +- .../dsh-prestep/specs/session-prestep/spec.md | 272 ++++++++++++++++++ openspec/specs/approval-gates/spec.md | 20 +- 6 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/dsh-prestep/specs/session-prestep/spec.md diff --git a/dsh/PORT.md b/dsh/PORT.md index e89c821..3222d30 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -2,7 +2,9 @@ Status: **spike 0.2.1, live guard/require-plan/bootstrap verified; `/role` command works, the model-invocable `role` skill and the asynchronous SDD -protocol are verified live (three-task trial run)**. This directory is the +protocol are verified live (three-task trial run); the pre-step +`harness-check`/`role-hint` injections are implemented and tested but not yet +verified live — they need the reinstalled bundle**. This directory is the DeepSeek Harness adapter for `claude-harness`; the Claude Code installation and the repository's source of truth are unchanged. @@ -16,6 +18,7 @@ This is a port, not a copy. Three layers map differently: | Skills | `~/.claude/skills`, `.claude/skills` | `~/.dsh/skills`, `.agents/skills`, `customSkillDirs` | 1:1 format | | Roles | `roles/`, `/role` skill | same files + `/role` command + model-invocable `role` skill | close | | Hard rules | `PreToolUse` shell hooks | `ctx.tools.guard` Cordis guard + approval gates A/B/C | logic reused, wrapper new | +| Session-start checks | `harness-check.sh`, `role-hint.sh` | `agent/pre-step` injections `harness-check`, `role-hint` | checks ported, texts new | | Superpowers | plugin + `SessionStart` hook | pinned sync of tag `v7.0.1` (`DSH_SUPERPOWERS_REF`) into `~/.dsh/skills` + `agent/pre-step` bootstrap | content adapted by sync, bootstrap new | | SDD | `Task` (synchronous) | `subagent` (asynchronous) / `workflow` | semantics differ | | Gitea | CLI + skills | `bash` + skills | 1:1 | @@ -61,11 +64,22 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read проекта, а копия старела бы молча. Слэш-команд нет — только скиллы. Установка в проект — `bash dsh/install.sh --openspec`; проверка — `--check`. +6. **`harness-check` and `role-hint`** through `agent/pre-step` (change + `dsh-prestep`, capability `session-prestep`). `harness-check` walks up to the + nearest `.git` and looks for `openspec/`, `.codegraph/`, and the + `## OpenSpec и superpowers` section in `CLAUDE.md`; a missing signal produces + one non-blocking `[dsh-harness]` message with the setup steps. `role-hint` + reads the first non-harness user message of the step and injects either + `Роль сессии: ` or one question with the four variants. Both are text-only + (`lib/prestep.js` uses fs reads, no spawn), skipped for subagents, gated by + `harnessCheck` / `roleHint` and by the same marker/visibility machinery as the + bootstrap. The Claude Code `SessionStart` scripts are not run under DSH. + ## 3. What is deliberately deferred -- **`harness-check` and `role-hint`**: non-blocking SessionStart hooks. They can - become `agent/pre-step` injections later; the first message role rule already - covers most of `role-hint`. +- **CC `SessionStart` scripts**: `hooks/harness-check.sh` and + `hooks/role-hint.sh` stay for Claude Code sessions; DSH runs the ported checks + itself (§2 item 6), so the scripts and `settings.hooks.json` are not reused. - **`briefing`**: needs an agent preset with read-only tools and a model route; not part of the guard/bootstrap spike. - **SDD parity**: the Claude `Task` is synchronous, while DSH `subagent` returns @@ -107,6 +121,7 @@ dsh/ command-role.js # /role command glue (DSH imports live only here) lib/guards.js # Claude-hook JSON adapter, fail-open lib/bootstrap.js # skill resolution, marker, dedupe, compaction + lib/prestep.js # harness-check/role-hint detection and texts (pure) lib/role.js # role parsing and text building (pure) dsh-tools.md # superpowers tool mapping hooks -> ../../hooks # local-install symlink to the tested bash/Python hooks @@ -153,7 +168,7 @@ agent/pre-step -> next() (other injections happen first) -> skip if reject/aborted, or if subagent and bootstrapSubagents=false -> prepareBootstrap(): resolve skill, read mapping, hash content, build text - -> isBootstrapVisible(agent, decision.messages, marker)? + -> isInjectionVisible(agent, decision.messages, marker)? yes -> no-op no -> append createUserMessage({ source: {kind:"user"} }) ``` @@ -162,6 +177,28 @@ The marker is an HTML comment at the top of the message. The visible-surface scan uses `agent.session.surface.nodes` + `eventAt(seq)`, the same mechanism the skill catalog uses, so a compacted message is re-injected on the next step. +### Pre-step injection flow (harness-check, role-hint) + +``` +agent/pre-step (after next(), same listener as the bootstrap) + -> harness-check: sessionCwd -> detectAdoption() + no .git up to the fs root -> silent + all signals present -> silent + otherwise -> [dsh-harness] Проект … не подключён … + -> role-hint: first user message of this step that is not a harness injection + no such message -> silent (autonomous round) + keyword match -> [dsh-harness] Роль сессии: … + no match -> the four-variant question + -> skip both for subagents (origin=subagent, delegationDepth>0, subagentDepth>0) + -> dedupe by the PRESTEP marker, which is the hash of the injection text +``` + +The checks are gated by `typeof ctx.on === 'function'` rather than by the +`bootstrap` self-check (D-5), so `bootstrap: false` keeps them alive. The +`harness-check` texts contain no Claude Code paths or adopt script (D-9); the +pre-step prefix is `[dsh-harness]`, and the message filter also accepts the +legacy `[claude-harness]` (D-10). + ## 5. Configuration The bundle row can be configured from the profile patch. Defaults are suitable @@ -178,6 +215,8 @@ for the repository layout: selfCheck: true # activation check of the DSH contract; not-ok is logged checkCommand: true # register the dsh-harness-check command bootstrapSubagents: false + harnessCheck: true # pre-step: unadopted project -> setup steps (non-blocking) + roleHint: true # pre-step: first user message -> role hint or question hooksDir: '' # empty -> bundle/hooks -> ../../hooks rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ projectRolesDir: '' # empty -> /.claude/roles @@ -401,7 +440,17 @@ profile was removed; the real `desktop` profile was not touched. is still pending. - **Subagent bootstrap.** `isSubagent` probes common fields; if none are present on this version, the bootstrap reaches children too. The skill's - `` makes that harmless but token-expensive. + `` makes that harmless but token-expensive. The pre-step + injections use the same predicate and are skipped for subagents; a fork + (`parentSession` without `origin`) is not a subagent and keeps them (T2.3). +- **Pre-step injection limits.** `harness-check` and `role-hint` are text + injections, not enforcement: nothing stops a model from ignoring them, and they + only exist in a session with the reinstalled bundle (D-13). `role-hint` matches + keywords as substrings, so a different stem («миграцию», «спеку») does not + match. Dedupe is per content (marker = hash of the text), not "exactly once per + session": a later step whose first user message has no keyword adds the + four-variant question even after an earlier role hint (D-11). `harness-check` + never adopts a project — there is no DSH port of `adopt-project.sh` (D-9). - **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. The port uses two live routes (`deepseek-flash`, `deepseek-v4-pro`) from the opt-in overlay in `dsh/subagent-models.yml`; without it every dispatch runs on the session model. @@ -427,8 +476,10 @@ profile was removed; the real `desktop` profile was not touched. 1. Finish the live smoke test: the fresh-session `role` skill acceptance, plus the bootstrap and `/compact` checks already verified. -2. Add `harness-check` and optional `role-hint` as non-blocking pre-step - injections. +2. `harness-check` and optional `role-hint` as non-blocking pre-step injections — + **done** (change `dsh-prestep`, capability `session-prestep`): implemented and + tested in `dsh/bundle/lib/prestep.js` + `index.js`; live verification waits for + the bundle reinstall. 3. Build `briefing` as an agent preset (read-only tools + model route). 4. SDD — **done** (change `dsh-sdd`): the `sdd` skill (async controller, reports, review, fix-loop, ledger) plus the overlay in §5; proven by a diff --git a/dsh/README.md b/dsh/README.md index 84ea039..0a9aaca 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -15,6 +15,11 @@ separate Cordis bundle plus a thin install helper. `/approve design|plan`; - superpowers bootstrap — `using-superpowers` is injected once per session from `agent/pre-step` and re-injected after compaction; +- pre-step injections — `harness-check` (a project that is not wired into the + harness is offered the setup steps: `openspec/`, `.codegraph/`, the + `## OpenSpec и superpowers` section in `CLAUDE.md`) and `role-hint` (the first + user message of a step names a role or asks one question with the four + variants); both are non-blocking, text-only, and skipped for subagents; - roles — the model loads them with the `role` skill (`~/.dsh/roles`, then `~/.claude/roles`, then repo `roles/`); `/role dev|admin|analyst|qa` remains the user-facing command and injects the same text, without Claude's @@ -31,9 +36,11 @@ separate Cordis bundle plus a thin install helper. and logs every missing service; the `dsh-harness-check` command re-runs the same check on demand. -**Not yet:** `briefing`, the `harness-check` SessionStart hook, `role-hint`, -`harness-doctor`, auto-memory, caveman. See [PORT.md](PORT.md) for the design, -mapping table, and risks. +**Not yet:** `briefing`, `harness-doctor`, auto-memory, caveman. The Claude Code +`SessionStart` hooks themselves stay in `hooks/` for Claude Code sessions; the +DSH port runs the same two checks as pre-step injections (see above) and does +not run the CC scripts. See [PORT.md](PORT.md) for the design, mapping table, +and risks. ## Quick start @@ -119,6 +126,9 @@ step, never a background pull (design D-2, cadence D-7): any code. 6. In a project with `.agents/skills`, ask `what skills do you have?` — the six `openspec-*` skills must be listed. +7. In a repository with `.git` but no `openspec/`, expect one + `[dsh-harness] Проект … не подключён к обвязке` message; a first message like + `Напиши тест для модуля` must be followed by `[dsh-harness] Роль сессии: dev`. The injected bootstrap starts with a marker: @@ -129,6 +139,13 @@ The injected bootstrap starts with a marker: After compaction the marker disappears from `session.surface.nodes` and the next step injects a fresh copy. +The two pre-step injections use the same shape; the hash is of the injection +text, so a different hint is a different marker: + +```text + +``` + ## Tests ```bash @@ -153,6 +170,7 @@ dsh/ lib/guards.js DSH tool call -> claude-harness hook JSON lib/gates.js approval gates A/B/C and approval.md/plan io (pure) lib/bootstrap.js superpowers skill resolution, marker, dedupe + lib/prestep.js harness-check/role-hint detection and texts (pure) lib/role.js role layers and text (pure) lib/selfcheck.js bundle contract self-check (pure) dsh-tools.md superpowers tool mapping @@ -190,6 +208,8 @@ repository layout: selfCheck: true # activation check of the DSH contract; not-ok is logged checkCommand: true # register the dsh-harness-check command bootstrapSubagents: false + harnessCheck: true # pre-step: unadopted project -> setup steps (non-blocking) + roleHint: true # pre-step: first user message -> role hint or question hooksDir: '' # empty -> bundle/hooks -> ../../hooks rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ projectRolesDir: '' # empty -> /.claude/roles @@ -240,6 +260,22 @@ repository layout: skill and `openspec/changes/dsh-sdd/`. - OpenSpec-скиллы проект получает сам (`openspec init --tools agents` или `--openspec`); обвязка ставит их только в проект, не в `~/.dsh`. +- Pre-step injections (`harness-check`, `role-hint`) are active only in a session + with the reinstalled bundle (D-13): an old install has neither the texts nor the + wiring, and a session in an unadopted project stays silent. +- `role-hint` matches keywords as substrings, so a word form that changes the stem + does not match: «миграцию»/«миграции» do not hit `миграция`, «спеку» does not + hit `спека`. Forms that contain the keyword whole («теста», «issue-трекер») do + match; `pr` matches only as a whole word, so `preview` is not dev. +- Injection dedupe is per content (marker = hash of the text), not "exactly once + per session": a later step whose first user message carries no keyword adds the + four-variant question even after an earlier role hint. Both texts stay in the + session and the model follows the one that fits; this is the declared contract + (D-11), not a defect. +- `harness-check` is a hint, never adoption: it writes nothing, and there is no + DSH port of `adopt-project.sh`; the owner runs `openspec init --tools agents`, + `codegraph init`, and adds the `## OpenSpec и superpowers` section to + `CLAUDE.md`. - The bundle self-checks the DSH contract it depends on at activation: `ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands, `workingDirectory.get`. Each missing service is logged as diff --git a/dsh/bundle/dsh-tools.md b/dsh/bundle/dsh-tools.md index e786051..c4f011a 100644 --- a/dsh/bundle/dsh-tools.md +++ b/dsh/bundle/dsh-tools.md @@ -21,6 +21,7 @@ On DeepSeek Harness these actions resolve to the tools below. | Load a skill | `skill` | | Present a deliverable | `present` | | Approve a plan | `exit_plan_mode` | +| Request an approval | `request_approval {change_id, kind}` — the model initiates it; the owner clicks Approve/Cancel | | Change directory | `working_directory` | | Create a worktree | `create_worktree` when the bundle is enabled, otherwise `bash` with `git worktree` | @@ -42,5 +43,10 @@ On DeepSeek Harness these actions resolve to the tools below. working directory. The `CLAUDE.md` files of this harness need no rewrite. - **`todo_write` is the task-tracking action.** Older `TodoWrite` references mean this tool. +- **An approval is the owner's click, not the model's claim.** + `request_approval({change_id, kind})` starts the flow: the harness writes the + approval marker only after the owner answers Approve; Cancel writes nothing. + Never treat "I asked" or "the tool was called" as approval. `/approve` in the + Claude Code text is the same idea through a slash command. - **Plan mode is a real mode.** Use `exit_plan_mode` to submit a plan; prose agreement does not leave plan mode. diff --git a/openspec/changes/dsh-prestep/design.md b/openspec/changes/dsh-prestep/design.md index d7c69c8..16ac30e 100644 --- a/openspec/changes/dsh-prestep/design.md +++ b/openspec/changes/dsh-prestep/design.md @@ -1,7 +1,9 @@ # Design: dsh-prestep — harness-check и role-hint через agent/pre-step -Issue: #43 (+#62). Статус: черновик до builder-check; утверждение — -`/approve dsh-prestep design`. +Issue: #43 (+#62). Статус: утверждён владельцем 25.10.2026 +(`/approve dsh-prestep design`); реализация — ветка `feat/43-dsh-prestep`, +статусы решений — в конце файла. Спека-дельта — +`openspec/changes/dsh-prestep/specs/session-prestep/spec.md`. ## Контекст @@ -71,6 +73,7 @@ bootstrap). | D-8 | T3.4: в sdd-скилле предложения про TDD-порядок нет — текст добавляется с нуля (пин в тесте), а не «усиливается» | Факт builder-check | Нет | | D-9 | DSH-автономность (замечание владельца 25.10): в текстах и сигналах дизайна нет CC-артефактов — ни `~/.claude`-путей, ни `adopt-project.sh`, ни `.claude/code-paths`; CC-хуки harness-check.sh/role-hint.sh живут только для CC-сессий; DSH-порт adopt-скрипта — отдельная фича (non-goal этого change) | Порт должен жить самостоятельно; CC-сторона — параллельный мир | Подключение проекта в DSH остаётся ручным до порта adopt-скрипта — инъекция даёт точный список шагов | | D-10 | Префикс DSH-инъекций — `[dsh-harness]` (не `[claude-harness]`); фильтр собственных сообщений — по нему и по `` + общий `isInjectionVisible` | реализовано (a2453e3) | `lib/prestep.js` `makePrestepMarker`, `lib/bootstrap.js` | +| D-7 | Текст вопроса с четырьмя вариантами | реализовано | `buildRoleHintText(null)`; пины в тестах | +| D-8 | TDD-текст в `sdd`-скилле добавлен с нуля | реализовано (183f8d8) | `dsh/skills/sdd/SKILL.md`, «Порядок TDD» | +| D-9 | DSH-автономность: без `~/.claude`-путей и adopt-скрипта | реализовано | тексты `lib/prestep.js` | +| D-10 | Префикс `[dsh-harness]`, фильтр обоих префиксов | реализовано | `isHarnessMessage` | +| D-11 | Дедуп по содержимому, не «ровно раз за сессию» | реализовано как факт (a2453e3) | заявлено в спеке `session-prestep`, требование «Повторная инъекция и видимость» | + +Статусы `T1.2`, `T2.2`, `T1.3` и `T1.5` (оба из #77) — в спеке-дельте +`session-prestep` и `openspec/specs/approval-gates/spec.md`; ledger — +`.superpowers/sdd/2026-10-25-dsh-prestep/progress.md`. diff --git a/openspec/changes/dsh-prestep/specs/session-prestep/spec.md b/openspec/changes/dsh-prestep/specs/session-prestep/spec.md new file mode 100644 index 0000000..27c88e1 --- /dev/null +++ b/openspec/changes/dsh-prestep/specs/session-prestep/spec.md @@ -0,0 +1,272 @@ +# Spec Delta: session-prestep + +## Purpose + +Две неблокирующие инъекции шага `agent/pre-step` в DSH-бандле: `harness-check` +(проект не подключён к обвязке → список отсутствующего и шаги настройки) и +`role-hint` (первое пользовательское сообщение шага подсказывает роль или задаёт +один вопрос с четырьмя вариантами). В Claude Code это SessionStart-хуки +`harness-check.sh` и `role-hint.sh`; в DSH слоя shell-хуков нет, поэтому та же +логика живёт в бандле и использует общую с bootstrap машинерию маркера и +видимости. Обе инъекции — текстовые, неблокирующие и отключаются конфигом. +Здесь же едут follow-ups #62 того же цикла: изоляция PATH в негативном тесте, +WARN смешанного HOME/DSH_HOME и обязательный TDD-порядок в `sdd`-скилле. +Тексты собираются модулем `dsh/bundle/lib/prestep.js` (чистые функции: только +fs-чтения, без `ctx`-сервисов и без `spawn`), а решает об инъекции +`dsh/bundle/index.js`. + +## ADDED Requirements + +### Requirement: Инъекция harness-check + +Инъекция SHALL проверять подключение проекта к обвязке и MUST оставаться +неблокирующей: при неподключённом проекте шаг продолжается, к сообщениям шага +добавляется одно пользовательское сообщение с текстом подсказки. + +Корень проекта MUST определяться upward-walk от рабочей директории сессии до +ближайшего каталога с `.git` (каталог или файл — worktree-чекаут тоже корень); +при отсутствии `.git` до корня ФС проект считается вне git и инъекции нет. +Сигналы подключения MUST быть ровно три и проверяться в этом порядке: +`openspec/` (каталог), `.codegraph/` (каталог), секция `## OpenSpec и +superpowers` в `CLAUDE.md` (строка заголовка второго уровня). Все сигналы на +месте — инъекция MUST NOT появляться; часть отсутствует — инъекция SHALL +перечислять отсутствующие в этом порядке. + +Текст инъекции MUST быть: `[dsh-harness] Проект не подключён к обвязке: +нет: <список отсутствующего через `; `>. Предложи владельцу настройку: +openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в +CLAUDE.md.` В тексте MUST NOT быть Claude Code-артефактов: ни путей `~/.claude`, +ни `adopt-project.sh`, ни `.claude/code-paths` (D-9). + +Инъекция SHALL управляться конфигом `harnessCheck` (по умолчанию `true`) вместе +с общим `enabled` бандла и MUST требовать только наличия `ctx.on` +(`typeof ctx.on === 'function'`, D-5), а не успеха self-check `bootstrap`: при +`bootstrap: false` harness-check обязан работать. Реализация MUST использовать +fs-чтения (`stat`/`readFile`) и MUST NOT запускать `git`. + +#### Scenario: Проект не подключён + +- **WHEN** сессия стартует в git-репозитории, где нет `openspec/` и секции в + `CLAUDE.md`, а `.codegraph/` есть +- **THEN** в сообщения шага добавляется `[dsh-harness] Проект не + подключён к обвязке: нет: openspec/; секция «OpenSpec и superpowers» в + CLAUDE.md.` и готовые команды настройки + +#### Scenario: Проект подключён + +- **WHEN** в корне проекта есть `openspec/`, `.codegraph/` и секция `## OpenSpec + и superpowers` в `CLAUDE.md` +- **THEN** harness-check молчит, сообщений не добавляется + +#### Scenario: Вне git-репозитория + +- **WHEN** рабочая директория сессии не имеет `.git` ни в одном родительском + каталоге до корня ФС +- **THEN** инъекции нет: harness-check не подсказывает настройку вне проекта + +#### Scenario: Рабочая директория в подкаталоге проекта + +- **WHEN** сессия стартует в `/dsh/bundle`, а сигналы лежат в `` +- **THEN** корнем считается ``, и текст инъекции называет `` + +#### Scenario: Отключённый bootstrap не выключает harness-check + +- **WHEN** профиль задаёт `bootstrap: false` и `harnessCheck: true` +- **THEN** harness-check по-прежнему инъецирует подсказку: гейт — `ctx.on`, а не + `bootstrapOk` + +#### Scenario: Конфиг выключает инъекцию + +- **WHEN** профиль задаёт `harnessCheck: false` +- **THEN** harness-check не инъецируется, остальные pre-step инъекции не + затрагиваются + +### Requirement: Инъекция role-hint + +Инъекция SHALL выбирать роль по первому пользовательскому сообщению шага; она +MUST NOT блокировать шаг и MUST NOT утверждать роль как факт. + +Триггер MUST быть таким: в сообщениях текущего шага есть сообщение с +`source.kind === 'user'`, чей текст непуст и не является собственной инъекцией +обвязки. Собственной инъекцией MUST считаться текст, начинающийся с +``, который строится из хэша текста +инъекции и стоит первой строкой тела; одна и та же видимая инъекция MUST NOT +добавляться дважды. Видимость SHALL проверяться общим хелпером +`isInjectionVisible(agent, messages, marker)`: сначала сообщения текущего шага, +затем durable-поверхность `session.surface.nodes` + `eventAt(seq)`; после +компакции маркер исчезает с поверхности и инъекция повторяется (D-6). + +Дедупликация MUST быть по содержимому (маркеру), а не «ровно один раз за +сессию» (T2.2). Фактическое поведение, которое и заявляется контрактом: + +- роль, определённая на другом шаге иначе, даёт другой текст и другой маркер — + повторная подсказка роли возможна; +- если на более позднем шаге первый пользовательский текст бесключевой, + role-hint может выдать вопрос с четырьмя вариантами после уже выданной + подсказки роли: это следствие per-text дедупа, а не дефект (D-11); +- harness-check при неизменном состоянии проекта даёт тот же текст и тот же + маркер — повтор возможен только после компакции или изменения набора + отсутствующих сигналов. + +#### Scenario: Та же инъекция не дублируется + +- **WHEN** тот же текст инъекции уже виден в сообщениях шага или на поверхности + сессии +- **THEN** вторая копия не добавляется + +#### Scenario: Другой текст даёт вторую инъекцию (T2.2) + +- **WHEN** подсказка роли уже выдана на первом шаге, а на следующем шаге первое + пользовательское сообщение бесключевое +- **THEN** добавляется вопрос с четырьмя вариантами: маркер другого текста не + виден, и дедуп по содержимому его не подавляет + +#### Scenario: Повтор после компакции + +- **WHEN** маркер инъекции пропал с поверхности сессии после `/compact` +- **THEN** следующий шаг добавляет инъекцию заново + +#### Scenario: Смена роли на новом шаге + +- **WHEN** на первом шаге роль была dev, а на следующем сообщение содержит + `jira` +- **THEN** добавляется подсказка admin: текст и маркер другие + +### Requirement: Follow-ups #62 в этом же change + +Пункты #62 SHALL ехать задачами этого change, а не отдельными фичами (D-4), и +каждый MUST быть закрыт тестом или пином текста: + +- T2.1/M-2: негативный тест `install.sh --check` без `openspec` MUST изолировать + PATH (каталог-заглушка как весь PATH), а не наследовать host PATH с + фиксированным системным префиксом: глобальный npm-префикс на Linux кладёт + `openspec` в `/usr/bin`, и тест «бинарника нет» иначе проверяет не то. +- O-2: `install.sh` MUST предупреждать (WARN, прогон продолжается) о смешанном + состоянии, когда `DSH_HOME` пришёл из окружения, указывает на существующий + каталог и не равен `$HOME/.dsh`, при нестандартном HOME: изоляция только HOME + пишет в реальный `DSH_HOME`. WARN MUST быть один на прогон и MUST + срабатывать и в `--global` (`install_global`), а не только в `--skills`. +- T3.4: `dsh/skills/sdd/SKILL.md` MUST содержать порядок TDD: шаг «напиши тест и + убедись, что он падает (RED)» обязателен до кода; текст добавляется с нуля и + пинится тестом (D-8). + +#### Scenario: Негативный тест `--check` без openspec + +- **WHEN** тест запускает `install.sh --check` с PATH, состоящим только из + каталога-заглушки без `openspec` +- **THEN** проверка сообщает об отсутствии `openspec`, а host-бинарник не влияет + на результат + +#### Scenario: Смешанный HOME и DSH_HOME + +- **WHEN** `--global` запускается с нестандартным HOME и `DSH_HOME`, указывающим + на существующий каталог +- **THEN** печатается `WARN: DSH_HOME=… both HOME and DSH_HOME must be isolated + together`, и установка продолжается + +#### Scenario: TDD-порядок в sdd-скилле + +- **WHEN** модель читает `sdd`-скилл перед первым диспатчем +- **THEN** в нём есть требование обязательного падающего теста до кода, и это + требование закреплено тестом diff --git a/openspec/specs/approval-gates/spec.md b/openspec/specs/approval-gates/spec.md index 9bca38d..84fb5b4 100644 --- a/openspec/specs/approval-gates/spec.md +++ b/openspec/specs/approval-gates/spec.md @@ -61,8 +61,13 @@ MUST отказать с перечислением отсутствующих Подтверждение владельца MUST идти через `ctx.userQuestions.ask` (D-12): вопрос с вариантами Approve и Cancel; без сервиса команда MUST отказать, а не писать. При подтверждении пишутся `by: owner` и `at` в ISO 8601 UTC; при отмене или -неподтверждении — отказ `not confirmed`, файл не меняется. Команда MUST NOT -создавать план и не трогать `proposal.md`, `design.md`, `builder-check.md`. +неподтверждении — отказ `not confirmed`, файл не меняется. Сбой самого диалога +(исключение из `ctx.userQuestions.ask`) MUST давать отказ с текстом ошибки — +`userQuestions failed: `, — а не литерал `not confirmed` (T1.5 #77): +владелец должен видеть причину, по которой вопрос не дошёл, а отмена и сбой +различаются в логе. `approval.md` не меняется ни в одном из этих случаев. Команда +MUST NOT создавать план и не трогать `proposal.md`, `design.md`, +`builder-check.md`. #### Scenario: /approve design без builder-check @@ -93,6 +98,17 @@ MUST отказать с перечислением отсутствующих - **THEN** команда отказывает с `userQuestions service unavailable; /approve requires it` и `approval.md` не пишется +#### Scenario: Сбой диалога вопросов + +- **WHEN** `ctx.userQuestions.ask` завершается исключением +- **THEN** команда отказывает с `userQuestions failed: `, а не с + литералом `not confirmed`, и `approval.md` не меняется + +#### Scenario: Отмена в диалоге + +- **WHEN** владелец ответил Cancel (или вопрос остался без ответа) +- **THEN** команда отказывает с `not confirmed` и `approval.md` не меняется + ### Requirement: Гейт A — план до утверждения дизайна Для write/edit/multi_edit/`str_replace_editor` с целевым путём под -- 2.47.2 From 7a5d7745f57944fafa39b52f40e9c9ef9d29ecaa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 12:49:55 +0400 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20design-=D0=BC=D0=B5=D1=85=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0=20per-text,=20=D0=B1=D1=8D=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=B2=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B5,?= =?UTF-8?q?=20=D1=81=D1=87=D1=91=D1=82=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B5=D0=B2=2023=20(T4.1-T4.4,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/dsh-prestep/design.md | 8 +++++--- .../changes/dsh-prestep/specs/session-prestep/spec.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openspec/changes/dsh-prestep/design.md b/openspec/changes/dsh-prestep/design.md index 16ac30e..4a22710 100644 --- a/openspec/changes/dsh-prestep/design.md +++ b/openspec/changes/dsh-prestep/design.md @@ -13,9 +13,11 @@ CC-хуки harness-check/role-hint — SessionStart; в DSH аналог — и ## Механика -Обе инъекции — раз за сессию, с маркером `` -и тем же механизмом видимости, что у bootstrap (`isBootstrapVisible` -переименован в общий `isInjectionVisible(agent, messages, marker)`). +Каждая инъекция — по содержимому и маркеру (per-text dedupe); повторная +инъекция с другим текстом возможна — см. D-11. Маркер +`` и тот же механизм видимости, что у +bootstrap (`isBootstrapVisible` переименован в общий +`isInjectionVisible(agent, messages, marker)`). Порядок в pre-step: другие инъекции → harness-check → role-hint (обе неблокирующие; reject/abort — пропуск; сабагенты — пропуск, как bootstrap). diff --git a/openspec/changes/dsh-prestep/specs/session-prestep/spec.md b/openspec/changes/dsh-prestep/specs/session-prestep/spec.md index 27c88e1..56bbdfe 100644 --- a/openspec/changes/dsh-prestep/specs/session-prestep/spec.md +++ b/openspec/changes/dsh-prestep/specs/session-prestep/spec.md @@ -33,7 +33,7 @@ superpowers` в `CLAUDE.md` (строка заголовка второго ур перечислять отсутствующие в этом порядке. Текст инъекции MUST быть: `[dsh-harness] Проект не подключён к обвязке: -нет: <список отсутствующего через `; `>. Предложи владельцу настройку: +нет: <список отсутствующего через '; '>. Предложи владельцу настройку: openspec init --tools agents, codegraph init и секцию «OpenSpec и superpowers» в CLAUDE.md.` В тексте MUST NOT быть Claude Code-артефактов: ни путей `~/.claude`, ни `adopt-project.sh`, ни `.claude/code-paths` (D-9). -- 2.47.2 From 557005219c9b9c1a9cf79f791f95a43ba48d60a7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 13:03:50 +0400 Subject: [PATCH 17/17] =?UTF-8?q?docs:=20=D1=83=D0=B4=D0=B0=D0=BB=D1=91?= =?UTF-8?q?=D0=BD=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=84=D0=BB=D0=B8=D0=BA=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BA=D0=B5=D1=80=D0=B0=20=D0=B2=20approval-gates?= =?UTF-8?q?=20(I-1,=20#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/specs/approval-gates/spec.md | 1 - 1 file changed, 1 deletion(-) diff --git a/openspec/specs/approval-gates/spec.md b/openspec/specs/approval-gates/spec.md index 85f9481..2fcf7ea 100644 --- a/openspec/specs/approval-gates/spec.md +++ b/openspec/specs/approval-gates/spec.md @@ -178,7 +178,6 @@ sha256), и любой отказ MUST NOT писать `approval.md`. Подт - **WHEN** `userQuestions` отсутствует - **THEN** инструмент возвращает `{approved: false}` с `userQuestions service unavailable`, владелец не спрашивается, `approval.md` не пишется ->>>>>>> main ### Requirement: Гейт A — план до утверждения дизайна -- 2.47.2